"""Days Without Strife - Planner optimizer. This module models the *Planner* side of Days Without Strife: a Faction's Industry Actions over a number of Turns, and finds the plan that maximizes a weighted score over the end-game resources. It uses Google OR-Tools (CP-SAT). What is modeled --------------- For each Turn each controlled City may take exactly one Industry Action: * COLLECT - gain Resources based on the City's type * RENOVATE - change the City's type (not the central Metropolis) * UPGRADE - spend Steel to install one of the City's Upgrades * LAUNCH - spend 7 Steel to launch an Airship (+3 Renown) * IDLE - do nothing City types and their Collection (most cost 1 Capital): Hub : [+2 Capital] OR [spend 1 Capital -> +2 Luxuries] Foundry : 3 vats (Steel/Brass/Electrum). Collect one vat -> spend 1 Capital, gain X (the vat's current level), empty that vat, and +1 to the other two vats. Monument : spend 1 Capital -> +2 Renown to this City Metropolis : spend 1 Capital -> +2 Trade Goods and +1 Renown to this City Upgrades (Steel cost in brackets, each grants +1 City Renown unless noted): Infrastructure [0] : -1 to the cost of future Upgrades here (no Renown) Harvester [2] : +1 to Collection yield Fine Dining [2] : (Hub) +2 bonus Luxuries whenever Resources collected Overflow Vats [2] : (Foundry) the two un-collected vats get +1 extra Transit Auth. [2] : (Metropolis) +1 Express Ticket whenever collected Propaganda [2] : (Monument) military only - no resource effect Fortification [4] : +2 Renown (military defense otherwise) Governors / Overwork: The Planner Leader has *Overwork*: when appointed Governor of a City it doubles that City's Collection that Turn and waives the Capital cost, but the City cannot Collect the following Turn. The Planner can govern at most one City per Turn. Generic governor Agents may also grant a free Upgrade or bonus Trade Goods on Collect. Objective: For each scored resource n, either ``scalar_n * amount_n`` (linear) or ``scalar_n * log_mapping[n][amount_n]`` (a caller-supplied lookup table). "Renown" is scored as the total final Renown of controlled assets (Cities + Agents + Airships), each capped at 1..9. Simplifications (documented): * Trade Goods may be exchanged 1-for-1 into other resources only at the end of the horizon (for scoring), via ``tradeable_into``. * Buying Trade Goods with Electrum (2.5 each, "awful") is not modeled. * Only resource-relevant mechanics are modeled; combat/diplomacy are not. """ from __future__ import annotations import json import sys from dataclasses import dataclass, field, asdict from enum import Enum from typing import Optional from ortools.sat.python import cp_model # --------------------------------------------------------------------------- # # Constants # --------------------------------------------------------------------------- # # Stockpiled resources (Renown is handled separately - it is not stockpiled). RESOURCES = [ "capital", "luxuries", "steel", "brass", "electrum", "trade_goods", "express", ] # Integer scale applied to (possibly fractional) objective scalars / table # values so the CP-SAT objective stays integral. OBJ_SCALE = 1000 # Upper bound used to bound resource accumulators (and AddElement domains in # log mode). Override via Problem.max_resource if your game runs hotter. DEFAULT_MAX_RESOURCE = 300 DEFAULT_MAX_VAT = 12 # foundry vats are uncapped in the rules; bounded here. RENOWN_MIN, RENOWN_MAX = 1, 9 AIRSHIP_COST_STEEL = 7 AIRSHIP_RENOWN = 3 AIRSHIP_MAX = 3 # each Faction can Launch at most 3 Airships total class CityType(str, Enum): HUB = "hub" FOUNDRY = "foundry" MONUMENT = "monument" METROPOLIS = "metropolis" class Action(str, Enum): IDLE = "idle" COLLECT = "collect" RENOVATE = "renovate" UPGRADE = "upgrade" LAUNCH = "launch" # Upgrade keys and their base Steel cost / Renown gain. UPGRADES = { "infrastructure": dict(cost=0, renown=0), "harvester": dict(cost=2, renown=1), "fine_dining": dict(cost=2, renown=1), # hub "overflow_vats": dict(cost=2, renown=1), # foundry "transit_authority": dict(cost=2, renown=1), # metropolis "propaganda": dict(cost=2, renown=1), # monument "fortification": dict(cost=4, renown=2), } # Which type-specific "third" upgrade each city type may install. TYPE_UPGRADE = { CityType.HUB: "fine_dining", CityType.FOUNDRY: "overflow_vats", CityType.METROPOLIS: "transit_authority", CityType.MONUMENT: "propaganda", } # Reverse map: which city type a type-specific upgrade belongs to. UPGRADE_OF_TYPE = {u: ct for ct, u in TYPE_UPGRADE.items()} # Upgrades that apply to any city type and persist permanently. The remaining # upgrades are type-specific (the "third" upgrade) and only exist while the City # is that type - renovating to a new type removes the old type's upgrade and # unlocks the new one. UNIVERSAL_UPGRADES = {"infrastructure", "harvester", "fortification"} # --------------------------------------------------------------------------- # # Input dataclasses # --------------------------------------------------------------------------- # @dataclass class City: name: str type: CityType # Starting city renown. Cities start at 2, except the central Metropolis at # 4 (rulebook p.18). Left as None => defaulted by type in __post_init__. renown: Optional[int] = None # Foundry vats: starting level of each vat resource. vat_steel: int = 0 vat_brass: int = 0 vat_electrum: int = 0 upgrades: list[str] = field(default_factory=list) # already-installed available_turns: Optional[list[int]] = None # None => all turns 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) def __post_init__(self): if self.renown is None: self.renown = 4 if self.type == CityType.METROPOLIS else 2 def is_available(self, t: int) -> bool: return self.available_turns is None or t in self.available_turns @dataclass class Agent: """A Faction Agent that can be appointed Governor. The Planner Leader (overwork=True) is the main lever. Other industrial agents can be expressed with the generic effect flags. """ name: str overwork: bool = False # Planner: double + waive cost free_upgrade: bool = False # e.g. Brotherhood Builder bonus_trade_goods: int = 0 # e.g. Baron: +N Trade Goods on collect 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) def is_available(self, t: int) -> bool: return self.available_turns is None or t in self.available_turns # -- Named constructors for known governor Agents -------------------- # @classmethod def planner(cls, name: str = "Planner", **kw) -> "Agent": """Faction Planner Leader: Overwork (double collection, waive Capital cost, lock next-Turn collect on the governed City).""" return cls(name=name, overwork=True, **kw) @classmethod def baron(cls, name: str = "Baron", bastions: int = 3, **kw) -> "Agent": """Brotherhood Baron: as Governor, gains +1 Trade Good per Bastion on the map during Collection. Defaults to 3 Bastions per request.""" return cls(name=name, bonus_trade_goods=bastions, **kw) @classmethod def builder(cls, name: str = "Builder", **kw) -> "Agent": """Brotherhood Builder: as Governor, the City gains its type-specific 3rd Upgrade for free (modeled as waiving the Steel cost of the Upgrade installed that Turn).""" return cls(name=name, free_upgrade=True, **kw) @dataclass class Objective: # mode: "linear" -> score scalar*amount ; "log" -> scalar*log_mapping[amount] mode: str = "linear" # Per-resource weight. Key is a resource name or "renown". scalars: dict[str, float] = field(default_factory=dict) # Only used when mode == "log": resource/renown -> [value@0, value@1, ...]. log_mapping: dict[str, list[float]] = field(default_factory=dict) @dataclass class Problem: turns: int = 5 cities: list[City] = field(default_factory=list) agents: list[Agent] = field(default_factory=list) start: dict[str, float] = field(default_factory=dict) # resource -> amount objective: Objective = field(default_factory=Objective) # Final Renown of assets that are not modeled Cities (other agents, # already-launched airships, etc.) - added as a constant to the score. extra_renown: int = 0 # Airships this Faction has already launched (counts toward the max of 3). airships_launched: int = 0 # Trade Goods may be converted 1-for-1 into these resources for scoring. tradeable_into: list[str] = field( default_factory=lambda: ["capital", "luxuries", "steel", "brass", "electrum"] ) max_resource: int = DEFAULT_MAX_RESOURCE max_vat: int = DEFAULT_MAX_VAT # --------------------------------------------------------------------------- # # Output dataclasses # --------------------------------------------------------------------------- # @dataclass class CityTurnPlan: turn: int city: str action: str detail: str = "" # e.g. collect choice, upgrade name, renovate target governor: str = "" # agent appointed governor (if any) overwork: bool = False @dataclass class Solution: status: str objective_value: float final_resources: dict[str, float] final_renown_total: int plan: list[CityTurnPlan] = field(default_factory=list) # Mid-game Trade Goods conversions: list of {turn, resource, amount}. trade_conversions: list[dict] = field(default_factory=list) # --------------------------------------------------------------------------- # # Model construction # --------------------------------------------------------------------------- # class _Builder: def __init__(self, problem: Problem): self.p = problem self.m = cp_model.CpModel() self.T = problem.turns self.MAXR = problem.max_resource self.MAXV = problem.max_vat # resource accumulators: res[name][t] = amount at END of turn t. # res[name][-1] handled via start; we index 0..T-1 and seed turn 0. self.res: dict[str, list[cp_model.IntVar]] = {} # resource production delta per (resource, turn). self.delta: dict[tuple[str, int], list] = {} # per (city_index, turn) action booleans self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {} # governor assignment: gov[(agent_idx, city_idx, turn)] bool self.gov: dict[tuple[int, int, int], cp_model.IntVar] = {} # overwork[(city_idx, turn)] bool (planner governs & overworks this city) self.overwork: dict[tuple[int, int], cp_model.IntVar] = {} # final renown per city self.city_final_renown: list[cp_model.IntVar] = [] # airship launch booleans (each adds AIRSHIP_RENOWN to asset renown) self.launches: list[cp_model.IntVar] = [] # bookkeeping for solution extraction self._collect_detail: dict[tuple[int, int], dict] = {} self._upgrade_choice: dict[tuple[int, int], dict] = {} self._renovate_choice: dict[tuple[int, int], dict] = {} # -- helpers ----------------------------------------------------------- # def _mul_bool(self, expr, b: cp_model.IntVar, ub: int): """Return an IntVar equal to expr * b where b is boolean, 0<=expr<=ub.""" y = self.m.NewIntVar(0, ub, "") self.m.Add(y <= expr) self.m.Add(y <= ub * b) self.m.Add(y >= expr - ub * (1 - b)) # y >= 0 by domain return y def _add_delta(self, resource: str, t: int, expr): self.delta.setdefault((resource, t), []).append(expr) # -- main build -------------------------------------------------------- # def build(self): self._build_actions_and_governors() self._build_city_dynamics() self._build_trade_conversion() self._build_resource_balance() self._build_objective() return self.m def _build_actions_and_governors(self): m = self.m cities, agents = self.p.cities, self.p.agents # Action selection: exactly one action per city per turn. for ci, city in enumerate(cities): for t in range(self.T): avail = city.is_available(t) vs = {} for a in Action: # Metropolis cannot renovate; renovate also needs can_renovate. if a == Action.RENOVATE and ( not city.can_renovate or city.type == CityType.METROPOLIS ): vs[a] = m.NewConstant(0) continue vs[a] = m.NewBoolVar(f"act_c{ci}_t{t}_{a.value}") self.act[(ci, t, a)] = vs[a] # store metropolis/no-renovate as constant for a in Action: self.act[(ci, t, a)] = vs[a] if not avail: # City unavailable: forced idle. for a in Action: if a != Action.IDLE: m.Add(vs[a] == 0) m.Add(vs[Action.IDLE] == 1) else: m.Add(sum(vs[a] for a in Action) == 1) # Forced action constraint. if t in city.forced_action and avail: forced = Action(city.forced_action[t]) m.Add(vs[forced] == 1) # Governor assignment. for ai, agent in enumerate(agents): for t in range(self.T): if not agent.is_available(t): continue gv = [] for ci in range(len(cities)): g = m.NewBoolVar(f"gov_a{ai}_c{ci}_t{t}") self.gov[(ai, ci, t)] = g gv.append(g) # Each agent governs at most one city per turn. m.Add(sum(gv) <= 1) # Forced governor placement. if t in agent.forced_city: target = agent.forced_city[t] ci = self._city_index(target) m.Add(self.gov[(ai, ci, t)] == 1) # Each city has at most one governor per turn. for ci in range(len(cities)): for t in range(self.T): govs = [ self.gov[(ai, ci, t)] for ai in range(len(agents)) if (ai, ci, t) in self.gov ] if govs: m.Add(sum(govs) <= 1) # Overwork bool: planner (overwork agent) governs this city. overwork_agents = [ai for ai, a in enumerate(agents) if a.overwork] for ci in range(len(cities)): for t in range(self.T): contrib = [ self.gov[(ai, ci, t)] for ai in overwork_agents if (ai, ci, t) in self.gov ] ow = m.NewBoolVar(f"overwork_c{ci}_t{t}") if contrib: m.Add(ow == sum(contrib)) # at most one planner total else: m.Add(ow == 0) self.overwork[(ci, t)] = ow # Only one overworking placement at a time is already implied by # the agent's "<=1 city" constraint. def _city_index(self, name: str) -> int: for i, c in enumerate(self.p.cities): if c.name == name: return i raise KeyError(f"Unknown city: {name}") def _build_city_dynamics(self): for ci, city in enumerate(self.p.cities): self._build_one_city(ci, city) def _build_one_city(self, ci: int, city: City): m = self.m T = self.T # --- City type over time (renovation) ------------------------------ # Built first because the installability and persistence of the # type-specific (3rd) upgrade depend on the City's *current* type. # We model an active-type indicator per turn; default = starting type. # Renovation target is chosen among the 3 other resource-types. # To keep collection math tractable we only allow collection math for # the *current* type; renovating just switches future behavior. type_active = {ct: [] for ct in CityType} renov_to = {} # (ct, t) -> bool : renovate to ct on turn t for t in range(T): for ct in CityType: type_active[ct].append(m.NewBoolVar(f"type_{ci}_{ct.value}_t{t}")) m.Add(sum(type_active[ct][t] for ct in CityType) == 1) renovate_allowed = ( city.can_renovate and city.type != CityType.METROPOLIS ) for t in range(T): renov_action = self.act[(ci, t, Action.RENOVATE)] choices = [] for ct in CityType: if ct == CityType.METROPOLIS: continue # cannot renovate *into* metropolis r = m.NewBoolVar(f"renov_{ci}_{ct.value}_t{t}") renov_to[(ct, t)] = r choices.append(r) if renovate_allowed and choices: m.Add(sum(choices) == renov_action) else: m.Add(renov_action == 0) for ct in CityType: if (ct, t) in renov_to: m.Add(renov_to[(ct, t)] == 0) self._renovate_choice[(ci, t)] = { ct.value: renov_to[(ct, t)] for ct in CityType if (ct, t) in renov_to } # Type transition: type@t = renov target if renovated, else type@t-1. for ct in CityType: prev = ( type_active[ct][t - 1] if t > 0 else m.NewConstant(1 if ct == city.type else 0) ) r = renov_to.get((ct, t)) if r is not None: # active(ct,t) == renovated_to_ct OR (prev AND not renovating) # Linearize: active = prev - prev*renov_action + r # since renovating sets exactly one r and clears others. not_renov_keep = self._mul_bool(prev, 1 - renov_action, 1) m.Add(type_active[ct][t] == not_renov_keep + r) else: # metropolis target impossible; keep only if not renovating. not_renov_keep = self._mul_bool(prev, 1 - renov_action, 1) m.Add(type_active[ct][t] == not_renov_keep) # --- Upgrade state ------------------------------------------------- # installed[u][t] = upgrade u is installed by END of turn t. installed = {u: [] for u in UPGRADES} # Universal upgrades apply to any type; type-specific (3rd) upgrades # only exist while the City is that type. A City can install a # type-specific upgrade for every type it can ever become (its starting # type, plus the renovate targets if renovation is allowed). reachable_types = {city.type} if renovate_allowed: reachable_types |= {CityType.HUB, CityType.FOUNDRY, CityType.MONUMENT} installable = set(UNIVERSAL_UPGRADES) | { TYPE_UPGRADE[ct] for ct in reachable_types } # installs driven by the City's UPGRADE Industry Action (cost Steel) act_install = {} # (u, t) -> bool # type-specific upgrade granted for free by a Builder Governor, applied # independently of whichever Industry Action the City takes. builder_free = {} # (u, t) -> bool # any new install this turn (action or Builder); used for Renown/detail. new_install = {} # (u, t) -> bool | expr for u in UPGRADES: for t in range(T): st = m.NewBoolVar(f"inst_{ci}_{u}_t{t}") installed[u].append(st) for u in UPGRADES: preinstalled = u in city.upgrades # The type a type-specific upgrade requires (None for universal). ct_req = UPGRADE_OF_TYPE.get(u) for t in range(T): if u not in installable: # type-specific upgrade for a type this City can't become. m.Add(installed[u][t] == 0) continue # "currently the right type" gate (1 for universal upgrades). type_gate = type_active[ct_req][t] if ct_req is not None else None prev = installed[u][t - 1] if t > 0 else m.NewConstant( 1 if preinstalled else 0 ) # A type-specific upgrade is lost if the City renovates away. keep = prev if type_gate is None else self._mul_bool(prev, type_gate, 1) ai = m.NewBoolVar(f"actinst_{ci}_{u}_t{t}") act_install[(u, t)] = ai if type_gate is not None: # can only action-install while currently this type. m.Add(ai <= type_gate) ni = ai if ct_req is not None: # Builder Governor: installs the City's current type-specific # upgrade for free this turn, regardless of its action. bf_src = self._free_upgrade_bool(ci, t) if bf_src is not None: bf = m.NewBoolVar(f"builderfree_{ci}_{u}_t{t}") # bf = bf_src AND currently this type AND not already kept m.Add(bf <= bf_src) m.Add(bf <= type_gate) m.Add(bf <= 1 - keep) m.Add(bf >= bf_src + type_gate + (1 - keep) - 2) builder_free[(u, t)] = bf ni = ai + bf # installed = keep OR new ; can't (re)install if already kept. m.Add(installed[u][t] == keep + ni) m.Add(keep + ni <= 1) new_install[(u, t)] = ni # At most one *action* upgrade per turn, and only if action==UPGRADE. # (Builder-granted installs are separate and do not consume the action.) for t in range(T): ups_this_turn = [ act_install[(u, t)] for u in UPGRADES if (u, t) in act_install ] up_action = self.act[(ci, t, Action.UPGRADE)] if ups_this_turn: m.Add(sum(ups_this_turn) == up_action) else: m.Add(up_action == 0) self._upgrade_choice[(ci, t)] = { u: new_install[(u, t)] for u in UPGRADES if (u, t) in new_install } # --- Collection ---------------------------------------------------- self._build_collection(ci, city, installed, type_active) # --- Foundry vats -------------------------------------------------- self._build_vats(ci, city, installed, type_active) # --- Steel spend for upgrades & airships --------------------------- for t in range(T): # Steel cost of the upgrade installed this turn, reduced by an # already-present Infrastructure (installed by previous turn). infra_prev = installed["infrastructure"][t - 1] if t > 0 else m.NewConstant( 1 if "infrastructure" in city.upgrades else 0 ) cost_terms = [] for u in UPGRADES: # Only action-driven upgrades cost Steel; Builder-granted # installs are free. if (u, t) not in act_install: continue base = UPGRADES[u]["cost"] if base == 0: continue ni = act_install[(u, t)] # effective cost = max(0, base - infra_prev) when installing u; # reduced if infra present: base*ni - infra_prev*ni discounted = self._mul_bool(infra_prev, ni, 1) cost_terms.append(base * ni - discounted) if cost_terms: self._add_delta("steel", t, -sum(cost_terms)) # Airship launch: -7 steel, +3 asset renown (added to renown total). launch = self.act[(ci, t, Action.LAUNCH)] self._add_delta("steel", t, -AIRSHIP_COST_STEEL * launch) self.launches.append(launch) # --- Overwork "no collect next turn" lock -------------------------- for t in range(T - 1): # if overworked at t, cannot collect at t+1 m.Add(self.act[(ci, t + 1, Action.COLLECT)] == 0).OnlyEnforceIf( self.overwork[(ci, t)] ) # Overwork requires a collection this turn (otherwise pointless, and a # governor that overworks implies the city collects). for t in range(T): m.Add(self.act[(ci, t, Action.COLLECT)] >= self.overwork[(ci, t)]) # --- Launches need available steel handled by balance; renown ------- # --- Final renown of the city -------------------------------------- self._build_city_renown(ci, city, installed, type_active) def _free_upgrade_bool(self, ci: int, t: int): agents = self.p.agents contrib = [] for ai, a in enumerate(agents): if a.free_upgrade and (ai, ci, t) in self.gov: contrib.append(self.gov[(ai, ci, t)]) if not contrib: return None b = self.m.NewBoolVar(f"freeup_c{ci}_t{t}") self.m.Add(b == sum(contrib)) return b def _bonus_tg_expr(self, ci: int, t: int): agents = self.p.agents terms = [] for ai, a in enumerate(agents): if a.bonus_trade_goods and (ai, ci, t) in self.gov: terms.append(a.bonus_trade_goods * self.gov[(ai, ci, t)]) return sum(terms) if terms else None def _build_collection(self, ci, city, installed, type_active): """Add per-turn resource deltas from Collection, for each possible type.""" m = self.m T = self.T for t in range(T): collect = self.act[(ci, t, Action.COLLECT)] ow = self.overwork[(ci, t)] harv = installed["harvester"][t] # multiplier: yield doubled when overworked. We compute base gain # then add the same again when ow=1. # Capital cost of collection is 1 unless overworked (waived); # applied per-type below. # ---- HUB (capital option) : +2 capital, no capital cost -------- hub_active = type_active[CityType.HUB][t] hub_collect = self._mul_bool(collect, hub_active, 1) # Hub sub-choice: capital vs luxuries hub_lux_choice = m.NewBoolVar(f"hublux_{ci}_t{t}") m.Add(hub_lux_choice <= hub_collect) hub_cap_choice = m.NewIntVar(0, 1, f"hubcap_{ci}_t{t}") m.Add(hub_cap_choice == hub_collect - hub_lux_choice) fine = installed["fine_dining"][t] # capital option: +2 (+harvester) capital, doubled if overwork cap_base = 2 * hub_cap_choice + self._mul_bool(harv, hub_cap_choice, 1) cap_total = cap_base + self._mul_bool(cap_base, ow, self.MAXR) self._add_delta("capital", t, cap_total) # capital option fine dining bonus luxuries fd_cap = self._mul_bool(fine, hub_cap_choice, 1) fd_cap_tot = 2 * fd_cap + self._mul_bool(2 * fd_cap, ow, self.MAXR) self._add_delta("luxuries", t, fd_cap_tot) # luxuries option: spend 1 capital -> +2 (+harvester) luxuries lux_base = 2 * hub_lux_choice + self._mul_bool(harv, hub_lux_choice, 1) # fine dining adds +2 lux too fd_lux = self._mul_bool(fine, hub_lux_choice, 1) lux_base = lux_base + 2 * fd_lux lux_total = lux_base + self._mul_bool(lux_base, ow, self.MAXR) self._add_delta("luxuries", t, lux_total) # capital cost for the luxuries option (waived under overwork) lux_cost = self._mul_bool(hub_lux_choice, 1 - ow, 1) self._add_delta("capital", t, -lux_cost) # ---- MONUMENT : spend 1 capital -> +2 city renown (renown later) mon_active = type_active[CityType.MONUMENT][t] mon_collect = self._mul_bool(collect, mon_active, 1) mon_cost = self._mul_bool(mon_collect, 1 - ow, 1) self._add_delta("capital", t, -mon_cost) # renown handled in _build_city_renown via mon_collect & ow & harv # ---- METROPOLIS : spend 1 capital -> +2 TG, +1 renown ---------- met_active = type_active[CityType.METROPOLIS][t] met_collect = self._mul_bool(collect, met_active, 1) transit = installed["transit_authority"][t] met_cost = self._mul_bool(met_collect, 1 - ow, 1) self._add_delta("capital", t, -met_cost) tg_base = 2 * met_collect + self._mul_bool(harv, met_collect, 1) tg_total = tg_base + self._mul_bool(tg_base, ow, self.MAXR) self._add_delta("trade_goods", t, tg_total) # transit authority: +1 express ticket on collect exp_base = self._mul_bool(transit, met_collect, 1) exp_total = exp_base + self._mul_bool(exp_base, ow, self.MAXR) self._add_delta("express", t, exp_total) # ---- bonus trade goods from governor agents (e.g. Baron) ------- # Baron grants +N Trade Goods on Collection (N = bonus per Bastion). # Applied whenever the city Collects (any type) while governed. btg = self._bonus_tg_expr(ci, t) if btg is not None: self._add_delta("trade_goods", t, self._gate_expr(btg, collect)) # store detail self._collect_detail[(ci, t)] = dict( hub_lux=hub_lux_choice, ) # NOTE: Foundry collection handled in _build_vats. def _gate_expr(self, expr, b): """Return expr if b else 0, for a small non-negative linear expr.""" ub = self.MAXR y = self.m.NewIntVar(0, ub, "") self.m.Add(y <= expr) self.m.Add(y <= ub * b) self.m.Add(y >= expr - ub * (1 - b)) return y def _build_vats(self, ci, city, installed, type_active): """Foundry vats and their collection. Vats grow only on collection.""" m = self.m T = self.T vat_res = {"steel": city.vat_steel, "brass": city.vat_brass, "electrum": city.vat_electrum} # vat[res][t] = level at START of turn t. vat = {r: [] for r in vat_res} for r, start in vat_res.items(): for t in range(T): v = m.NewIntVar(0, self.MAXV, f"vat_{ci}_{r}_t{t}") vat[r].append(v) m.Add(vat[r][0] == start) self._vat_choice = getattr(self, "_vat_choice", {}) for t in range(T): collect = self.act[(ci, t, Action.COLLECT)] ow = self.overwork[(ci, t)] harv = installed["harvester"][t] overflow = installed["overflow_vats"][t] found_active = type_active[CityType.FOUNDRY][t] found_collect = self._mul_bool(collect, found_active, 1) # choose which vat to collect (at most one, only if found_collect) pick = {} for r in vat_res: pick[r] = m.NewBoolVar(f"vatpick_{ci}_{r}_t{t}") m.Add(pick[r] <= found_collect) m.Add(sum(pick[r] for r in vat_res) == found_collect) self._vat_choice[(ci, t)] = pick # capital cost (waived under overwork) f_cost = self._mul_bool(found_collect, 1 - ow, 1) self._add_delta("capital", t, -f_cost) for r in vat_res: # yield if picked = vat level (+harvester), doubled if overwork picked_level = self._gate_expr(vat[r][t], pick[r]) gain_base = picked_level + self._mul_bool(harv, pick[r], 1) gain_total = gain_base + self._mul_bool(gain_base, ow, self.MAXR) self._add_delta(r, t, gain_total) # transition to next turn's vat levels if t + 1 < T: for r in vat_res: # if picked: -> 0 # else if some other vat picked (found_collect & not pick[r]): # +1 (+1 more if overflow) # else (no foundry collect): unchanged other_collect = m.NewBoolVar(f"vatother_{ci}_{r}_t{t}") # other_collect = found_collect AND not pick[r] m.Add(other_collect <= found_collect) m.Add(other_collect <= 1 - pick[r]) m.Add(other_collect >= found_collect - pick[r]) inc = other_collect + self._mul_bool(overflow, other_collect, 1) keep = self._gate_expr(vat[r][t], 1 - found_collect) # next = keep (if no foundry collect) + (vat[r][t]+inc if other) other_keep = self._gate_expr(vat[r][t], other_collect) nxt = keep + other_keep + inc # cap at MAXV m.Add(vat[r][t + 1] <= self.MAXV) m.Add(vat[r][t + 1] == nxt) def _build_city_renown(self, ci, city, installed, type_active): m = self.m T = self.T # renown accumulates from: starting renown + upgrade renown gains # + Monument collects (+2 ea, *2 if overwork) + Metropolis collects (+1) # + airship launches do NOT add to city renown (they add asset renown). gains = [] # upgrade renown: sum over upgrades newly installed of their renown. # installed[u][T-1] minus preinstalled tells if newly installed by end. for u, info in UPGRADES.items(): if info["renown"] == 0: continue end = installed[u][T - 1] pre = 1 if u in city.upgrades else 0 gains.append(info["renown"] * (end - pre)) for t in range(T): collect = self.act[(ci, t, Action.COLLECT)] mon_active = type_active[CityType.MONUMENT][t] met_active = type_active[CityType.METROPOLIS][t] mon_collect = self._mul_bool(collect, mon_active, 1) met_collect = self._mul_bool(collect, met_active, 1) # Renown is not a Resource, so Overwork (which doubles the Resources # Collected) does NOT double these Renown gains. gains.append(2 * mon_collect) # Monument: +2 Renown on collect gains.append(1 * met_collect) # Metropolis: +1 Renown on collect raw = m.NewIntVar(0, 1000, f"rawrenown_{ci}") m.Add(raw == city.renown + sum(gains)) # capped to [1, 9] capped = m.NewIntVar(RENOWN_MIN, RENOWN_MAX, f"renown_{ci}") m.AddMinEquality(capped, [raw, m.NewConstant(RENOWN_MAX)]) # raw >= 1 always (starts >=2), so min with 9 is enough; ensure >=1 self.city_final_renown.append(capped) def _build_trade_conversion(self): """Mid-game Trade Goods exchange: each Turn, convert Trade Goods 1-for-1 into any resource in ``tradeable_into``. The converted resource becomes available that same Turn (and onward). Over-conversion is prevented by the non-negative Trade Goods balance enforced each Turn.""" m = self.m self.trade_conv: dict[tuple[str, int], cp_model.IntVar] = {} # Express Tickets cannot be acquired via Trade Goods conversion. targets = [ r for r in self.p.tradeable_into if r in RESOURCES and r != "express" ] for t in range(self.T): for r in targets: c = m.NewIntVar(0, self.MAXR, f"tgconv_{r}_t{t}") self.trade_conv[(r, t)] = c self._add_delta(r, t, c) # +1 target resource self._add_delta("trade_goods", t, -c) # -1 Trade Good def _build_resource_balance(self): m = self.m T = self.T for r in RESOURCES: start = int(round(self.p.start.get(r, 0))) self.res[r] = [] for t in range(T): v = m.NewIntVar(0, self.MAXR, f"res_{r}_t{t}") self.res[r].append(v) prev = self.res[r][t - 1] if t > 0 else start deltas = self.delta.get((r, t), []) m.Add(v == prev + sum(deltas)) # resources must never go negative (enforced by domain >= 0). def _build_objective(self): m = self.m T = self.T obj = self.p.objective terms = [] # Each Faction can Launch at most 3 Airships total (incl. any already # launched before the planning horizon). if self.launches: m.Add(sum(self.launches) <= AIRSHIP_MAX - self.p.airships_launched) # Renown total = sum of city final renown + extra. renown_total = m.NewIntVar(0, 100000, "renown_total") launch_renown = AIRSHIP_RENOWN * sum(self.launches) if self.launches else 0 m.Add(renown_total == sum(self.city_final_renown) + self.p.extra_renown + launch_renown) self.renown_total = renown_total # Trade Goods conversion is handled mid-game (see _build_trade_conversion), # so end-of-game amounts are simply the resource balances at the last Turn. final_amt = {r: self.res[r][T - 1] for r in RESOURCES} self.final_amt = final_amt def scaled(x: float) -> int: return int(round(x * OBJ_SCALE)) if obj.mode == "linear": for r in RESOURCES: s = obj.scalars.get(r, 0.0) if s: terms.append(scaled(s) * final_amt[r]) s = obj.scalars.get("renown", 0.0) if s: terms.append(scaled(s) * renown_total) elif obj.mode == "log": for key, amt_var in ( [(r, final_amt[r]) for r in RESOURCES] + [("renown", renown_total)] ): s = obj.scalars.get(key, 0.0) if not s: continue table = obj.log_mapping.get(key) if table is None: raise ValueError(f"log mode requires log_mapping[{key!r}]") # value = table[min(amt, len-1)]; scale table values to ints. vals = [scaled(v) for v in table] idx = m.NewIntVar(0, len(table) - 1, f"idx_{key}") m.AddMinEquality(idx, [amt_var, m.NewConstant(len(table) - 1)]) val = m.NewIntVar(min(vals), max(vals), f"logval_{key}") m.AddElement(idx, vals, val) terms.append(int(round(s)) * val) else: raise ValueError(f"Unknown objective mode: {obj.mode}") m.Maximize(sum(terms)) # --------------------------------------------------------------------------- # # Solve entry point # --------------------------------------------------------------------------- # def solve(problem: Problem, max_time_seconds: float = 30.0, workers: int = 8) -> Solution: builder = _Builder(problem) model = builder.build() solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = max_time_seconds solver.parameters.num_search_workers = workers status = solver.Solve(model) status_name = solver.StatusName(status) if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): return Solution( status=status_name, objective_value=float("nan"), final_resources={}, final_renown_total=0, plan=[], ) return _extract(problem, builder, solver, status_name) def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solution: T = b.T plan: list[CityTurnPlan] = [] for ci, city in enumerate(problem.cities): for t in range(T): chosen = None for a in Action: v = b.act[(ci, t, a)] if solver.Value(v) == 1: chosen = a break if chosen is None or chosen == Action.IDLE: continue detail = "" governor = "" overwork = bool(solver.Value(b.overwork[(ci, t)])) # governor name for ai, agent in enumerate(problem.agents): if (ai, ci, t) in b.gov and solver.Value(b.gov[(ai, ci, t)]) == 1: governor = agent.name if chosen == Action.COLLECT: # determine collect detail pick = b._vat_choice.get((ci, t)) if pick and any(solver.Value(pick[r]) for r in pick): r = next(r for r in pick if solver.Value(pick[r])) detail = f"foundry vat: {r}" else: cd = b._collect_detail.get((ci, t), {}) if "hub_lux" in cd and solver.Value(cd["hub_lux"]) == 1: detail = "hub: +2 luxuries" else: detail = "collect" elif chosen == Action.UPGRADE: uc = b._upgrade_choice.get((ci, t), {}) for u, var in uc.items(): if solver.Value(var) == 1: detail = f"upgrade: {u}" elif chosen == Action.RENOVATE: rc = b._renovate_choice.get((ci, t), {}) for ct, var in rc.items(): if solver.Value(var) == 1: detail = f"renovate -> {ct}" elif chosen == Action.LAUNCH: detail = "launch airship" plan.append(CityTurnPlan( turn=t, city=city.name, action=chosen.value, detail=detail, governor=governor, overwork=overwork, )) final_resources = { r: float(solver.Value(b.final_amt[r])) for r in RESOURCES } conversions = [] for (r, t), var in sorted(b.trade_conv.items(), key=lambda kv: (kv[0][1], kv[0][0])): amt = solver.Value(var) if amt: conversions.append({"turn": t, "resource": r, "amount": amt}) return Solution( status=status_name, objective_value=solver.ObjectiveValue() / OBJ_SCALE, final_resources=final_resources, final_renown_total=int(solver.Value(b.renown_total)), plan=sorted(plan, key=lambda p: (p.turn, p.city)), trade_conversions=conversions, ) # --------------------------------------------------------------------------- # # JSON interface # --------------------------------------------------------------------------- # def problem_from_dict(d: dict) -> Problem: cities = [] for c in d.get("cities", []): c = dict(c) c["type"] = CityType(c["type"]) if "forced_action" in c: c["forced_action"] = {int(k): v for k, v in c["forced_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()} agents.append(Agent(**a)) obj = Objective(**d.get("objective", {})) kwargs = {k: v for k, v in d.items() if k not in ("cities", "agents", "objective")} return Problem(cities=cities, agents=agents, objective=obj, **kwargs) def solution_to_dict(s: Solution) -> dict: out = asdict(s) return out def main(argv: list[str]) -> int: import argparse ap = argparse.ArgumentParser(description="Days Without Strife planner optimizer") ap.add_argument("input", nargs="?", help="input JSON file (default stdin)") ap.add_argument("-o", "--output", help="output JSON file (default stdout)") ap.add_argument("--time", type=float, default=30.0, help="solver time limit (s)") args = ap.parse_args(argv) raw = open(args.input).read() if args.input else sys.stdin.read() problem = problem_from_dict(json.loads(raw)) sol = solve(problem, max_time_seconds=args.time) out = json.dumps(solution_to_dict(sol), indent=2) if args.output: with open(args.output, "w") as f: f.write(out) else: print(out) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))