1041 lines
44 KiB
Python
1041 lines
44 KiB
Python
"""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:]))
|