giving up on this in favor of complete redo

This commit is contained in:
Pagwin 2026-06-17 16:29:49 -04:00
parent 24f58765ac
commit d7b4bf79b1
No known key found for this signature in database
GPG key ID: 81137023740CA260
3 changed files with 387 additions and 74 deletions

407
solve.py
View file

@ -17,11 +17,12 @@ import printer
# PARAMETERS -- edit these # PARAMETERS -- edit these
# ====================================================================== # ======================================================================
# Starting resource pools at the start of step 1: (Electrum, Brass, Steel, Capital) # Starting resource pools at the start of step 1:
# (Electrum, Brass, Steel, Capital, Renown, Luxuries, Express tickets)
# Note: all resource values are scaled by 10 internally so that fractional # Note: all resource values are scaled by 10 internally so that fractional
# trade-good splits (Baron/Fence et al, phase B/C) can be modeled with integers. # trade-good splits (Baron/Fence et al, phase B/C) can be modeled with integers.
# Display in _report divides by 10. # Display in _report divides by 10.
INITIAL = (30, 30, 30, 30) INITIAL = (30, 30, 30, 30, 0, 0, 0)
# fixed_choices = { # fixed_choices = {
# "actions": { # "actions": {
@ -38,14 +39,15 @@ INITIAL = (30, 30, 30, 30)
# FIXED_CHOICES = None # FIXED_CHOICES = None
FIXED_CHOICES = {"actions": {}} FIXED_CHOICES = {"actions": {}}
# Resource constraints: list of callables that receive a dict with keys E, B, S, C # Resource constraints: list of callables that receive a dict with keys
# E, B, S, C, R (Renown), L (Luxuries), X (Express tickets)
# mapping to resource variables indexed by step. Each callable returns a constraint # mapping to resource variables indexed by step. Each callable returns a constraint
# (or None to skip) to be passed to m.Add(). Example: # (or None to skip) to be passed to m.Add(). Example:
# lambda res: res["E"][3] >= 50 # Ensure at least 50 E at step 3 # lambda res: res["E"][3] >= 50 # Ensure at least 50 E at step 3
RESOURCE_CONSTRAINTS = [] RESOURCE_CONSTRAINTS = []
# Maximize criteria for solve(). Factor = exponent in "product" mode, # Maximize criteria for solve(). Factor = exponent in "product" mode,
# weight in "sum" mode. Keys: E, B, S, C; missing keys = 0 (resource # weight in "sum" mode. Keys: E, B, S, C, R, L, X; missing keys = 0 (resource
# excluded from the objective — it is NOT forced to zero). Negative # excluded from the objective — it is NOT forced to zero). Negative
# factors are allowed only in "sum" mode (a negative exponent would mean # factors are allowed only in "sum" mode (a negative exponent would mean
# division, which integer CP-SAT cannot express). # division, which integer CP-SAT cannot express).
@ -56,7 +58,7 @@ OBJECTIVE_MODE = "product" # "product" or "sum"
def _validate_objective(factors, mode): def _validate_objective(factors, mode):
if mode not in ("product", "sum"): if mode not in ("product", "sum"):
raise ValueError(f"objective_mode must be 'product' or 'sum', got {mode!r}") raise ValueError(f"objective_mode must be 'product' or 'sum', got {mode!r}")
unknown = set(factors) - {"E", "B", "S", "C"} unknown = set(factors) - {"E", "B", "S", "C", "R", "L", "X"}
if unknown: if unknown:
raise ValueError(f"unknown objective_factors keys: {sorted(unknown)}") raise ValueError(f"unknown objective_factors keys: {sorted(unknown)}")
for k, v in factors.items(): for k, v in factors.items():
@ -76,8 +78,12 @@ def _validate_objective(factors, mode):
# Arriving cities act that same step. # Arriving cities act that same step.
# Each entry may be either a string shorthand (e.g. "H") or a dict, e.g. # Each entry may be either a string shorthand (e.g. "H") or a dict, e.g.
# {"type": "F", "adjacent_to": [city_idx, ...], "vats": {"E": 1, "B": 1, "S": 1}, # {"type": "F", "adjacent_to": [city_idx, ...], "vats": {"E": 1, "B": 1, "S": 1},
# "departure_step": NUM_STEPS + 1} # "base": False, "departure_step": NUM_STEPS + 1}
# `adjacent_to` is currently parsed but unused; see normalize_city(). # `adjacent_to` lists city indices considered adjacent to this city; it is
# used by the Industrialist agent (Network grants flow from the governed
# city to the cities listed in ITS adjacent_to).
# Optional `base` marks the city as having a Base (used by Provisioner);
# defaults to False.
# Optional `vats` overrides initial vat values at arrival (each defaults to 1). # Optional `vats` overrides initial vat values at arrival (each defaults to 1).
# Optional `departure_step` is the first step at which the city is ABSENT; # Optional `departure_step` is the first step at which the city is ABSENT;
# default NUM_STEPS+1 means the city stays through the whole simulation. # default NUM_STEPS+1 means the city stays through the whole simulation.
@ -107,6 +113,7 @@ def normalize_city(c):
return { return {
"type": c["type"], "type": c["type"],
"adjacent_to": c.get("adjacent_to", []), "adjacent_to": c.get("adjacent_to", []),
"base": c.get("base", False),
"vats": vats, "vats": vats,
"departure_step": c.get("departure_step", NUM_STEPS + 1), "departure_step": c.get("departure_step", NUM_STEPS + 1),
} }
@ -141,6 +148,12 @@ AGENT_AVAILABILITY = {
"fence": [], "fence": [],
"foreman": [], "foreman": [],
"industrialist": [], "industrialist": [],
"vinter": [],
"artificer": [],
"connoisseur": [],
"economist": [],
"influencer": [],
"cosmopolitan": [],
} }
# ====================================================================== # ======================================================================
@ -753,7 +766,10 @@ class EventAgent:
class Capitalist(Agent): class Capitalist(Agent):
"""+20 Capital (scaled = +2 Capital) when governing a Collect action.""" """+20 Capital (scaled = +2 Capital) when governing a Collect that
actually yields Capital: Hub collects, or Metropolis collects that pick
at least one Capital. Foundry collects yield no Capital and get no bonus
(agents.txt: "increases Capital Collection by +2")."""
def apply(self, model, i, t, ctx): def apply(self, model, i, t, ctx):
m = model m = model
@ -763,11 +779,25 @@ class Capitalist(Agent):
col = ctx["col"].get((i, t)) col = ctx["col"].get((i, t))
if col is None: if col is None:
return return
isH = ctx["isH"]
mC_var = ctx["mC"].get((i, t))
# cap_yield: this city's Collect yields Capital (Hub always; Metro
# only when its picks include Capital, i.e. mC > 0).
if mC_var is not None:
mc_pos = m.NewBoolVar("")
m.Add(mC_var >= 10).OnlyEnforceIf(mc_pos)
m.Add(mC_var == 0).OnlyEnforceIf(mc_pos.Not())
cap_yield = m.NewBoolVar("")
m.AddMaxEquality(cap_yield, [isH[i, t], mc_pos])
else:
cap_yield = isH[i, t]
both = m.NewBoolVar("") both = m.NewBoolVar("")
m.AddMultiplicationEquality(both, [g, col]) m.AddMultiplicationEquality(both, [g, col])
active = m.NewBoolVar("")
m.AddMultiplicationEquality(active, [both, cap_yield])
bonus = m.NewIntVar(0, 20, "") bonus = m.NewIntVar(0, 20, "")
m.Add(bonus == 20).OnlyEnforceIf(both) m.Add(bonus == 20).OnlyEnforceIf(active)
m.Add(bonus == 0).OnlyEnforceIf(both.Not()) m.Add(bonus == 0).OnlyEnforceIf(active.Not())
ctx["gain_C"][t].append(bonus) ctx["gain_C"][t].append(bonus)
@ -793,41 +823,63 @@ class Baron(Agent):
class Prodigy(Agent): class Prodigy(Agent):
"""+20 Steel (refund 2 Steel) when governing AND city upgrades B or D.""" """Refund "up to 2 Steel" when governing AND city upgrades B or D.
The refund is capped at the Steel actually spent: upgrades cost 20
scaled Steel normally but only 10 if the city already holds the
cost-reduction upgrade (a), so the refund matches (10 with hasA, else 20).
Airship launches are not modeled, so that half of the ability is out of
scope."""
def apply(self, model, i, t, ctx): def apply(self, model, i, t, ctx):
m = model m = model
g = ctx["governor"].get((i, t, self.name)) g = ctx["governor"].get((i, t, self.name))
if g is None: if g is None:
return return
hasA = ctx["hasA"]
for u_var in (ctx["ub"].get((i, t)), ctx["ud"].get((i, t))): for u_var in (ctx["ub"].get((i, t)), ctx["ud"].get((i, t))):
if u_var is None: if u_var is None:
continue continue
both = m.NewBoolVar("") both = m.NewBoolVar("")
m.AddMultiplicationEquality(both, [g, u_var]) m.AddMultiplicationEquality(both, [g, u_var])
refund = m.NewIntVar(0, 20, "") refund = m.NewIntVar(0, 20, "")
m.Add(refund == 20).OnlyEnforceIf(both) m.Add(refund == 20).OnlyEnforceIf(both, hasA[i, t].Not())
m.Add(refund == 10).OnlyEnforceIf(both, hasA[i, t])
m.Add(refund == 0).OnlyEnforceIf(both.Not()) m.Add(refund == 0).OnlyEnforceIf(both.Not())
ctx["gain_S"][t].append(refund) ctx["gain_S"][t].append(refund)
class Provisioner(Agent): class Provisioner(Agent):
"""+15 Electrum (+1.5 scaled) when governing, unconditional.""" """+15 Electrum (=1.5 Electrum scaled) when APPOINTED Governor of a City
with a Base. Pays once per appointment: governs at t but did not govern
at t-1 (governing at the first step counts as an appointment). A city
declares a Base via the `base` flag in its arrival entry (default False)."""
def apply(self, model, i, t, ctx): def apply(self, model, i, t, ctx):
m = model m = model
g = ctx["governor"].get((i, t, self.name)) g = ctx["governor"].get((i, t, self.name))
if g is None: if g is None:
return return
cities = ctx.get("cities")
if cities is None or not cities[i][1].get("base", False):
return
prev = ctx["governor"].get((i, t - 1, self.name), 0) if t > 1 else 0
# appoint = g AND NOT prev (linear form; prev may be a constant)
appoint = m.NewBoolVar("")
m.Add(appoint <= g)
m.Add(appoint <= 1 - prev)
m.Add(appoint >= g - prev)
bonus = m.NewIntVar(0, 15, "") bonus = m.NewIntVar(0, 15, "")
m.Add(bonus == 15).OnlyEnforceIf(g) m.Add(bonus == 15).OnlyEnforceIf(appoint)
m.Add(bonus == 0).OnlyEnforceIf(g.Not()) m.Add(bonus == 0).OnlyEnforceIf(appoint.Not())
ctx["gain_E"][t].append(bonus) ctx["gain_E"][t].append(bonus)
class Metallurgist(Agent): class Metallurgist(Agent):
"""Foundry-only: adds +1 to vat increment. Implemented centrally in solve(). """As Governor of a Foundry, grants the effect of the Overflow Vats
This class exists only for registry uniformity.""" Upgrade (upgrade d, +1 vat increment). Does NOT stack with the actual
Upgrade: the increment is 1 + max(hasD, metallurgist-governs-foundry).
Implemented centrally in solve()'s vat-transition block; this class
exists only for registry uniformity."""
def apply(self, model, i, t, ctx): def apply(self, model, i, t, ctx):
# Vat-level effect handled in solve()'s centralized vat-transition block. # Vat-level effect handled in solve()'s centralized vat-transition block.
@ -835,8 +887,11 @@ class Metallurgist(Agent):
class Builder(Agent): class Builder(Agent):
"""One-shot: grants hasD[i,t+1]=1 when governing a Foundry city. """Per appointment: grants the governed City its type-specific Upgrade.
Fires at most once across all (i,t).""" Only the Foundry type-specific Upgrade (Overflow Vats, hasD) is modeled,
so the grant fires only when governing a Foundry (hasD[i,t+1]=1 via the
central transition). Hub/Metropolis type-specific Upgrades are not
represented in this model; Builder is a no-op on those types."""
def declare_vars(self, model, ctx): def declare_vars(self, model, ctx):
ctx.setdefault("builder_fire", {}) ctx.setdefault("builder_fire", {})
@ -928,53 +983,156 @@ class Planner(Agent):
class Foreman(Agent): class Foreman(Agent):
"""TODO: Restructure ability — allows other industry actions during renovation. """Restructure: as Governor, Renovating the city does not prevent other
The current model uses 'exactly one action per city per step', so renovation Industry Actions the governed city may take one additional action in
already blocks no specific action separately. Foreman becomes meaningful only the same step as a Renovate. Wired centrally into the per-city action
once renovation-blocks-action semantics are modeled. count constraint in solve() (action_sum == present + foreman_extra);
""" this class exists only for registry uniformity."""
def apply(self, model, i, t, ctx): def apply(self, model, i, t, ctx):
pass pass
class Industrialist(Agent): class Industrialist(Agent):
"""Network ability — grants Infrastructure (hasA) to governed city and its """Network: when appointed Governor, the governed City and all cities
adjacent cities when appointed governor.""" listed in its `adjacent_to` gain the Infrastructure Upgrade.
ASSUMPTION: "Infrastructure Upgrade" is modeled as upgrade a
(cost-reduction / hasA). The grant is wired centrally in solve() as an
extra source of the hasA max-equality transition (indus_grant_map), so
it cannot conflict with the transition constraint, and grants targeting
cities that are absent (not yet arrived / departed) are naturally
dropped. This class exists only for registry uniformity."""
def apply(self, model, i, t, ctx):
pass
class Fence(EventAgent):
"""When hired (= first available step), one-time bonus: 1 Express Ticket
(+10 scaled into gain_X) and 2 Trade Goods (+20 scaled into tg_pool)."""
def apply_event(self, model, t, ctx):
if t != min(self.available_steps):
return
deposit = model.NewConstant(20)
ctx["tg_pool"][t].append(deposit)
ctx.setdefault("fence_deposits", {})[t] = 20
ticket = model.NewConstant(10)
ctx["gain_X"][t].append(ticket)
# ======================================================================
# PHASE D AGENTS
# ======================================================================
class Vinter(Agent):
"""As Governor, increases Luxury Collection by +2 (=+20 scaled L).
NOTE: no city type in this model yields Luxuries from a Collect
(Hub -> Capital, Foundry -> vat resource, Metropolis picks E/B/S/C),
so this agent currently has NO effect; it is registered for parity with
agents.txt and will need wiring if a Luxury-yielding collection is ever
modeled. Intentional no-op until then (mirrors the Capitalist rule that
the bonus applies only to collections actually yielding the resource)."""
def apply(self, model, i, t, ctx):
# No Luxury-yielding collections exist in the model; intentional no-op.
pass
class Artificer(Agent):
"""As Governor, Collects an extra Trade Good: +10 scaled into tg_pool
when governing a city that takes Collect (analogue of Baron)."""
def apply(self, model, i, t, ctx): def apply(self, model, i, t, ctx):
m = model m = model
g = ctx["governor"].get((i, t, self.name)) g = ctx["governor"].get((i, t, self.name))
if g is None: if g is None:
return return
col = ctx["col"].get((i, t))
cities = ctx.get("cities") if col is None:
hasA = ctx.get("hasA")
NUM_STEPS = ctx.get("NUM_STEPS", 5)
if cities is None or hasA is None:
return return
both = m.NewBoolVar("")
a_step, a_city = cities[i] m.AddMultiplicationEquality(both, [g, col])
adjacent_indices = a_city.get("adjacent_to", []) deposit = m.NewIntVar(0, 10, "")
m.Add(deposit == 10).OnlyEnforceIf(both)
# When Industrialist governs city i at step t, grant hasA to city i and adjacent cities at t+1 m.Add(deposit == 0).OnlyEnforceIf(both.Not())
if t + 1 <= NUM_STEPS: ctx["tg_pool"][t].append(deposit)
# Grant to governed city
m.Add(hasA[i, t + 1] == 1).OnlyEnforceIf(g)
# Grant to adjacent cities
for adj_i in adjacent_indices:
m.Add(hasA[adj_i, t + 1] == 1).OnlyEnforceIf(g)
class Fence(EventAgent): class Cosmopolitan(Agent):
"""Each available step, deposits +20 (=2 trade goods x10) into tg_pool[t].""" """When appointed Governor, gains +1 Renown (+10 scaled R). Pays once
per appointment: governs at t but did not govern at t-1 (governing at
the first step counts as an appointment). The Forum-invitation half of
the ability is diplomacy-only and out of scope for this model."""
def apply(self, model, i, t, ctx):
m = model
g = ctx["governor"].get((i, t, self.name))
if g is None:
return
prev = ctx["governor"].get((i, t - 1, self.name), 0) if t > 1 else 0
# appoint = g AND NOT prev (linear form; prev may be a constant)
appoint = m.NewBoolVar("")
m.Add(appoint <= g)
m.Add(appoint <= 1 - prev)
m.Add(appoint >= g - prev)
bonus = m.NewIntVar(0, 10, "")
m.Add(bonus == 10).OnlyEnforceIf(appoint)
m.Add(bonus == 0).OnlyEnforceIf(appoint.Not())
ctx["gain_R"][t].append(bonus)
class Connoisseur(EventAgent):
"""When hired (= first available step), one-time bonus of 5 Luxuries
(+50 scaled into gain_L)."""
def apply_event(self, model, t, ctx): def apply_event(self, model, t, ctx):
deposit = model.NewConstant(20) if t != min(self.available_steps):
ctx["tg_pool"][t].append(deposit) return
ctx.setdefault("fence_deposits", {})[t] = 20 ctx["gain_L"][t].append(model.NewConstant(50))
class Influencer(EventAgent):
"""Gains +1 Renown (+10 scaled R) at the start of each Turn while hired
(i.e. each available step)."""
def apply_event(self, model, t, ctx):
ctx["gain_R"][t].append(model.NewConstant(10))
class Economist(EventAgent):
"""Gain 1 Renown (=10 scaled R) for every 10 Capital (=100 scaled C)
spent on Collection while hired. Consumes the capital_spent tracker
(Foundry/Metropolis collect costs). Cumulative across hired steps with
integer-division payout: at step t the gain is
10 * (floor(cum_t / 100) - floor(cum_{t-1} / 100))."""
def declare_vars(self, model, ctx):
self._cum = None # cumulative scaled-Capital collection spend so far
self._paid = None # renown units (x1, unscaled) already paid out
def apply_event(self, model, t, ctx):
m = model
cum_max = MAX_RES * NUM_STEPS
spent_terms = ctx["capital_spent"][t]
spent_t = m.NewIntVar(0, MAX_RES, f"econ_spent_{t}")
m.Add(spent_t == (sum(spent_terms) if spent_terms else 0))
cum = m.NewIntVar(0, cum_max, f"econ_cum_{t}")
if self._cum is None:
m.Add(cum == spent_t)
else:
m.Add(cum == self._cum + spent_t)
paid = m.NewIntVar(0, cum_max // 100, f"econ_paid_{t}")
m.AddDivisionEquality(paid, cum, 100)
gain = m.NewIntVar(0, 10 * (cum_max // 100), f"econ_gainR_{t}")
if self._paid is None:
m.Add(gain == 10 * paid)
else:
m.Add(gain == 10 * (paid - self._paid))
ctx["gain_R"][t].append(gain)
self._cum = cum
self._paid = paid
# ====================================================================== # ======================================================================
@ -1003,11 +1161,17 @@ def solve(
objective_mode = OBJECTIVE_MODE objective_mode = OBJECTIVE_MODE
_validate_objective(objective_factors, objective_mode) _validate_objective(objective_factors, objective_mode)
# Normalized copy: every key present, missing keys = 0. # Normalized copy: every key present, missing keys = 0.
obj_factors = {k: objective_factors.get(k, 0) for k in "EBSC"} obj_factors = {k: objective_factors.get(k, 0) for k in "EBSCRLX"}
# Pad short initial tuples (legacy 4-tuples) with 0 Renown/Luxuries/Express tickets.
initial = tuple(initial)
if len(initial) < 7:
initial = initial + (0,) * (7 - len(initial))
# ---- build the city list ----------------------------------------- # ---- build the city list -----------------------------------------
# cities: list[tuple[int, dict]] where the dict has keys "type" and # cities: list[tuple[int, dict]] where the dict has keys "type",
# "adjacent_to". TODO: adjacency unused; for Industrialist agent. # "adjacent_to", "base", "vats", "departure_step" (see normalize_city).
# Adjacency is consumed by the Industrialist grant wiring below.
cities = [] cities = []
for s in range(1, NUM_STEPS + 1): for s in range(1, NUM_STEPS + 1):
for entry in arrivals.get(s, []): for entry in arrivals.get(s, []):
@ -1064,6 +1228,12 @@ def solve(
gain_B = {t: [] for t in range(1, NUM_STEPS + 1)} gain_B = {t: [] for t in range(1, NUM_STEPS + 1)}
gain_S = {t: [] for t in range(1, NUM_STEPS + 1)} gain_S = {t: [] for t in range(1, NUM_STEPS + 1)}
gain_C = {t: [] for t in range(1, NUM_STEPS + 1)} gain_C = {t: [] for t in range(1, NUM_STEPS + 1)}
# Renown (R) and Express tickets (X) currently have NO production sources;
# their accumulators exist only for plumbing uniformity and stay empty.
# Luxuries (L) can only be gained via the trade-goods pool split below.
gain_R = {t: [] for t in range(1, NUM_STEPS + 1)}
gain_L = {t: [] for t in range(1, NUM_STEPS + 1)}
gain_X = {t: [] for t in range(1, NUM_STEPS + 1)}
cost_C = {t: [] for t in range(1, NUM_STEPS + 1)} cost_C = {t: [] for t in range(1, NUM_STEPS + 1)}
cost_S = {t: [] for t in range(1, NUM_STEPS + 1)} cost_S = {t: [] for t in range(1, NUM_STEPS + 1)}
@ -1137,6 +1307,9 @@ def solve(
"planner": Planner, "planner": Planner,
"foreman": Foreman, "foreman": Foreman,
"industrialist": Industrialist, "industrialist": Industrialist,
"vinter": Vinter,
"artificer": Artificer,
"cosmopolitan": Cosmopolitan,
} }
for name, cls in agent_classes.items(): for name, cls in agent_classes.items():
if name in AGENT_AVAILABILITY: if name in AGENT_AVAILABILITY:
@ -1144,6 +1317,9 @@ def solve(
event_agent_classes = { event_agent_classes = {
"fence": Fence, "fence": Fence,
"connoisseur": Connoisseur,
"economist": Economist,
"influencer": Influencer,
} }
for name, cls in event_agent_classes.items(): for name, cls in event_agent_classes.items():
if name in AGENT_AVAILABILITY: if name in AGENT_AVAILABILITY:
@ -1165,6 +1341,9 @@ def solve(
"gain_B": gain_B, "gain_B": gain_B,
"gain_S": gain_S, "gain_S": gain_S,
"gain_C": gain_C, "gain_C": gain_C,
"gain_R": gain_R,
"gain_L": gain_L,
"gain_X": gain_X,
"cost_C": cost_C, "cost_C": cost_C,
"cost_S": cost_S, "cost_S": cost_S,
"builder_fire": builder_fire_map, "builder_fire": builder_fire_map,
@ -1181,6 +1360,31 @@ def solve(
if governor.get((city, step, agent_name)) is not None: if governor.get((city, step, agent_name)) is not None:
m.Add(governor[city, step, agent_name] == (1 if value else 0)) m.Add(governor[city, step, agent_name] == (1 if value else 0))
# ---- Industrialist (Network) grant map -----------------------------
# When the Industrialist governs city i at step t, city i AND every city
# listed in cities[i]'s `adjacent_to` gain the Infrastructure Upgrade
# (ASSUMPTION: modeled as upgrade a / hasA). The grant vars are fed into
# the hasA max-equality transition sources below (same pattern as
# builder_fire -> hasD), so the grant can never contradict the
# transition. Grants aimed at cities that are absent at t+1 (not yet
# arrived or departed) are dropped automatically because no transition
# constraint is built for those steps.
indus_grant_map = {} # (target_city, t) -> [governor BoolVars]
if "industrialist" in AGENT_AVAILABILITY:
_indus_steps = set(AGENT_AVAILABILITY["industrialist"])
for i in range(N):
for t in range(1, NUM_STEPS + 1):
# Skip steps where the var is a hardwired 0 constant.
if t not in _indus_steps or t < arrival_step_map[i]:
continue
g = governor.get((i, t, "industrialist"))
if g is None:
continue
targets = [i] + list(cities[i][1].get("adjacent_to", []))
for j in targets:
if 0 <= j < N:
indus_grant_map.setdefault((j, t), []).append(g)
# ---- per-city logic ----------------------------------------------- # ---- per-city logic -----------------------------------------------
for i in range(N): for i in range(N):
a_step, a_city = cities[i] a_step, a_city = cities[i]
@ -1260,6 +1464,9 @@ def solve(
"gain_B": gain_B, "gain_B": gain_B,
"gain_S": gain_S, "gain_S": gain_S,
"gain_C": gain_C, "gain_C": gain_C,
"gain_R": gain_R,
"gain_L": gain_L,
"gain_X": gain_X,
"cost_C": cost_C, "cost_C": cost_C,
"cost_S": cost_S, "cost_S": cost_S,
"tg_pool": tg_pool, "tg_pool": tg_pool,
@ -1319,13 +1526,26 @@ def solve(
if r is not None: if r is not None:
m.Add(target[i, t] == 0).OnlyEnforceIf(r) m.Add(target[i, t] == 0).OnlyEnforceIf(r)
# Exactly one action while present (sum all action selectors) # Exactly one action while present (sum all action selectors).
# Foreman (Restructure): when the Foreman governs this city and
# it renovates, the renovation does not consume the action — the
# city MAY take one additional (non-renovate) action this step.
# `extra` is optional (<= foreman AND renovate) so a lone
# renovate is still legal under the Foreman.
action_sum = sum( action_sum = sum(
action.selector_var(i, t, ctx) action.selector_var(i, t, ctx)
for action in action_registry for action in action_registry
if action.selector_var(i, t, ctx) is not None if action.selector_var(i, t, ctx) is not None
) )
m.Add(action_sum == P) foreman_g = governor.get((i, t, "foreman"))
if foreman_g is not None and "foreman" in AGENT_AVAILABILITY:
fg_ren = AND(foreman_g, ren)
extra = m.NewBoolVar("")
m.Add(extra <= fg_ren)
m.Add(extra <= P)
m.Add(action_sum == P + extra)
else:
m.Add(action_sum == P)
# Agent per-(city, step) hooks - run BEFORE upgrade transitions so # Agent per-(city, step) hooks - run BEFORE upgrade transitions so
# Builder can register its fire var that feeds into d_keep. # Builder can register its fire var that feeds into d_keep.
@ -1334,9 +1554,11 @@ def solve(
# Upgrade transitions (skip propagation past last present step) # Upgrade transitions (skip propagation past last present step)
if t + 1 < d_step: if t + 1 < d_step:
m.AddMaxEquality( # Industrialist grants are extra OR-sources for hasA, exactly
hasA[i, t + 1], [hasA[i, t], ua.get((i, t), m.NewConstant(0))] # like builder_fire is for hasD below.
) a_sources = [hasA[i, t], ua.get((i, t), m.NewConstant(0))]
a_sources += indus_grant_map.get((i, t), [])
m.AddMaxEquality(hasA[i, t + 1], a_sources)
m.AddMaxEquality( m.AddMaxEquality(
hasB[i, t + 1], [hasB[i, t], ub.get((i, t), m.NewConstant(0))] hasB[i, t + 1], [hasB[i, t], ub.get((i, t), m.NewConstant(0))]
) )
@ -1388,6 +1610,10 @@ def solve(
_owcvB = owcvB.get((i, t)) _owcvB = owcvB.get((i, t))
_owcvS = owcvS.get((i, t)) _owcvS = owcvS.get((i, t))
# Vat increment: 1, +1 with Overflow Vats (hasD) OR the
# Metallurgist governing this Foundry. Non-stacking per
# agents.txt ("Does not stack with actual Upgrade"), hence
# max() rather than a sum.
inc = m.NewIntVar(1, 3, "") inc = m.NewIntVar(1, 3, "")
metallurgist_g = governor.get((i, t, "metallurgist")) metallurgist_g = governor.get((i, t, "metallurgist"))
if metallurgist_g is not None and "metallurgist" in AGENT_AVAILABILITY: if metallurgist_g is not None and "metallurgist" in AGENT_AVAILABILITY:
@ -1395,7 +1621,9 @@ def solve(
m.AddMultiplicationEquality( m.AddMultiplicationEquality(
metal_active, [metallurgist_g, isF[i, t]] metal_active, [metallurgist_g, isF[i, t]]
) )
m.Add(inc == 1 + metal_active) d_or_metal = m.NewBoolVar("")
m.AddMaxEquality(d_or_metal, [hasD[i, t], metal_active])
m.Add(inc == 1 + d_or_metal)
else: else:
m.Add(inc == 1 + hasD[i, t]) m.Add(inc == 1 + hasD[i, t])
@ -1450,10 +1678,6 @@ def solve(
m.Add(vB[i, t + 1] == 0).OnlyEnforceIf(not_F_next) m.Add(vB[i, t + 1] == 0).OnlyEnforceIf(not_F_next)
m.Add(vS[i, t + 1] == 0).OnlyEnforceIf(not_F_next) m.Add(vS[i, t + 1] == 0).OnlyEnforceIf(not_F_next)
# ---- Builder one-shot: at most one fire across all (i, t) ----
# if builder_fire_map:
# m.Add(sum(builder_fire_map.values()) <= 1)
# ---- global constraints (per action type, per step) ---- # ---- global constraints (per action type, per step) ----
for t in range(1, NUM_STEPS + 1): for t in range(1, NUM_STEPS + 1):
ctx_global = {"N": N, "ow": ow, "t": t} ctx_global = {"N": N, "ow": ow, "t": t}
@ -1471,6 +1695,9 @@ def solve(
"gain_B": gain_B, "gain_B": gain_B,
"gain_S": gain_S, "gain_S": gain_S,
"gain_C": gain_C, "gain_C": gain_C,
"gain_R": gain_R,
"gain_L": gain_L,
"gain_X": gain_X,
"cost_C": cost_C, "cost_C": cost_C,
"cost_S": cost_S, "cost_S": cost_S,
"fence_deposits": fence_deposits, "fence_deposits": fence_deposits,
@ -1481,11 +1708,11 @@ def solve(
if t in agent.available_steps: if t in agent.available_steps:
agent.apply_event(m, t, _event_ctx) agent.apply_event(m, t, _event_ctx)
# ---- trade-goods pool split (routes pool[t] -> gain_E/B/S/C) ---- # ---- trade-goods pool split (routes pool[t] -> gain_E/B/S/C/L) ----
# Generic-trade-good sources (Baron, Fence in later phases) deposit # Generic-trade-good sources (Baron, Fence in later phases) deposit
# IntVars into tg_pool[t]; here we declare a 4-way split into the # IntVars into tg_pool[t]; here we declare a 5-way split into the
# resource gain accumulators. If the pool is empty for step t, the split # resource gain accumulators (Luxuries can only be gained this way).
# vars are constrained to 0. # If the pool is empty for step t, the split vars are constrained to 0.
for t in range(1, NUM_STEPS + 1): for t in range(1, NUM_STEPS + 1):
pool_total = m.NewIntVar(0, max_res, f"tg_total_{t}") pool_total = m.NewIntVar(0, max_res, f"tg_total_{t}")
if tg_pool[t]: if tg_pool[t]:
@ -1496,32 +1723,44 @@ def solve(
tg_B = m.NewIntVar(0, max_res, f"tg_B_{t}") tg_B = m.NewIntVar(0, max_res, f"tg_B_{t}")
tg_S = m.NewIntVar(0, max_res, f"tg_S_{t}") tg_S = m.NewIntVar(0, max_res, f"tg_S_{t}")
tg_C = m.NewIntVar(0, max_res, f"tg_C_{t}") tg_C = m.NewIntVar(0, max_res, f"tg_C_{t}")
m.Add(tg_E + tg_B + tg_S + tg_C == pool_total) tg_L = m.NewIntVar(0, max_res, f"tg_L_{t}")
m.Add(tg_E + tg_B + tg_S + tg_C + tg_L == pool_total)
# Each trade good (10 scaled units) must be allocated entirely to one resource # Each trade good (10 scaled units) must be allocated entirely to one resource
m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_E, 10) m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_E, 10)
m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_B, 10) m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_B, 10)
m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_S, 10) m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_S, 10)
m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_L, 10)
gain_E[t].append(tg_E) gain_E[t].append(tg_E)
gain_B[t].append(tg_B) gain_B[t].append(tg_B)
gain_S[t].append(tg_S) gain_S[t].append(tg_S)
gain_C[t].append(tg_C) gain_C[t].append(tg_C)
gain_L[t].append(tg_L)
# ---- resource pool recursion -------------------------------------- # ---- resource pool recursion --------------------------------------
E = {1: m.NewIntVar(initial[0], initial[0], "E1")} E = {1: m.NewIntVar(initial[0], initial[0], "E1")}
B = {1: m.NewIntVar(initial[1], initial[1], "B1")} B = {1: m.NewIntVar(initial[1], initial[1], "B1")}
S = {1: m.NewIntVar(initial[2], initial[2], "S1")} S = {1: m.NewIntVar(initial[2], initial[2], "S1")}
C = {1: m.NewIntVar(initial[3], initial[3], "C1")} C = {1: m.NewIntVar(initial[3], initial[3], "C1")}
R = {1: m.NewIntVar(initial[4], initial[4], "R1")}
L = {1: m.NewIntVar(initial[5], initial[5], "L1")}
X = {1: m.NewIntVar(initial[6], initial[6], "X1")}
for t in range(1, NUM_STEPS + 1): for t in range(1, NUM_STEPS + 1):
E[t + 1] = m.NewIntVar(0, max_res, f"E_{t + 1}") E[t + 1] = m.NewIntVar(0, max_res, f"E_{t + 1}")
B[t + 1] = m.NewIntVar(0, max_res, f"B_{t + 1}") B[t + 1] = m.NewIntVar(0, max_res, f"B_{t + 1}")
S[t + 1] = m.NewIntVar(0, max_res, f"S_{t + 1}") S[t + 1] = m.NewIntVar(0, max_res, f"S_{t + 1}")
C[t + 1] = m.NewIntVar(0, max_res, f"C_{t + 1}") C[t + 1] = m.NewIntVar(0, max_res, f"C_{t + 1}")
R[t + 1] = m.NewIntVar(0, max_res, f"R_{t + 1}")
L[t + 1] = m.NewIntVar(0, max_res, f"L_{t + 1}")
X[t + 1] = m.NewIntVar(0, max_res, f"X_{t + 1}")
m.Add(C[t] - sum(cost_C[t]) >= 0) m.Add(C[t] - sum(cost_C[t]) >= 0)
m.Add(S[t] - sum(cost_S[t]) >= 0) m.Add(S[t] - sum(cost_S[t]) >= 0)
m.Add(E[t + 1] == E[t] + sum(gain_E[t])) m.Add(E[t + 1] == E[t] + sum(gain_E[t]))
m.Add(B[t + 1] == B[t] + sum(gain_B[t])) m.Add(B[t + 1] == B[t] + sum(gain_B[t]))
m.Add(S[t + 1] == S[t] - sum(cost_S[t]) + sum(gain_S[t])) m.Add(S[t + 1] == S[t] - sum(cost_S[t]) + sum(gain_S[t]))
m.Add(C[t + 1] == C[t] - sum(cost_C[t]) + sum(gain_C[t])) m.Add(C[t + 1] == C[t] - sum(cost_C[t]) + sum(gain_C[t]))
m.Add(R[t + 1] == R[t] + sum(gain_R[t]))
m.Add(L[t + 1] == L[t] + sum(gain_L[t]))
m.Add(X[t + 1] == X[t] + sum(gain_X[t]))
finalE, finalB, finalS = E[NUM_STEPS + 1], B[NUM_STEPS + 1], S[NUM_STEPS + 1] finalE, finalB, finalS = E[NUM_STEPS + 1], B[NUM_STEPS + 1], S[NUM_STEPS + 1]
@ -1537,6 +1776,9 @@ def solve(
"B": B, "B": B,
"S": S, "S": S,
"C": C, "C": C,
"R": R,
"L": L,
"X": X,
} }
for i, constraint_fn in enumerate(resource_constraints): for i, constraint_fn in enumerate(resource_constraints):
constraint = constraint_fn(res_dict) constraint = constraint_fn(res_dict)
@ -1560,7 +1802,15 @@ def solve(
else max_res else max_res
) )
finals = {"E": finalE, "B": finalB, "S": finalS, "C": C[NUM_STEPS + 1]} finals = {
"E": finalE,
"B": finalB,
"S": finalS,
"C": C[NUM_STEPS + 1],
"R": R[NUM_STEPS + 1],
"L": L[NUM_STEPS + 1],
"X": X[NUM_STEPS + 1],
}
# Ceilings only for resources that appear in the objective: each # Ceilings only for resources that appear in the objective: each
# ceiling solve costs up to 20s, and only objective resources need # ceiling solve costs up to 20s, and only objective resources need
@ -1651,6 +1901,9 @@ def solve(
B, B,
S, S,
C, C,
R,
L,
X,
finalE, finalE,
finalB, finalB,
finalS, finalS,
@ -1707,6 +1960,9 @@ def _report(
B, B,
S, S,
C, C,
R,
L,
X,
finalE, finalE,
finalB, finalB,
finalS, finalS,
@ -1804,14 +2060,15 @@ def _report(
return "(inactive)" return "(inactive)"
print("\nPer-step pools (start of step):") print("\nPer-step pools (start of step):")
print(" step: E B S C") print(" step: E B S C R L X")
for t in range(1, NUM_STEPS + 2): for t in range(1, NUM_STEPS + 2):
label = f"after5" if t == NUM_STEPS + 1 else f"start {t}" label = f"after5" if t == NUM_STEPS + 1 else f"start {t}"
c_val = solver.Value(C[t]) c_val = solver.Value(C[t])
c_str = f"{c_val / 10:6.1f}" c_str = f"{c_val / 10:6.1f}"
print( print(
f" {label:>8}: {solver.Value(E[t]) / 10:6.1f} {solver.Value(B[t]) / 10:6.1f} " f" {label:>8}: {solver.Value(E[t]) / 10:6.1f} {solver.Value(B[t]) / 10:6.1f} "
f"{solver.Value(S[t]) / 10:6.1f} {c_str}" f"{solver.Value(S[t]) / 10:6.1f} {c_str} {solver.Value(R[t]) / 10:6.1f} "
f"{solver.Value(L[t]) / 10:6.1f} {solver.Value(X[t]) / 10:6.1f}"
) )
if capital_spent is not None: if capital_spent is not None:
@ -1872,7 +2129,15 @@ def _report(
) )
fe, fb, fs = solver.Value(finalE), solver.Value(finalB), solver.Value(finalS) fe, fb, fs = solver.Value(finalE), solver.Value(finalB), solver.Value(finalS)
vals = {"E": fe, "B": fb, "S": fs, "C": solver.Value(C[NUM_STEPS + 1])} vals = {
"E": fe,
"B": fb,
"S": fs,
"C": solver.Value(C[NUM_STEPS + 1]),
"R": solver.Value(R[NUM_STEPS + 1]),
"L": solver.Value(L[NUM_STEPS + 1]),
"X": solver.Value(X[NUM_STEPS + 1]),
}
if obj_factors is None: if obj_factors is None:
# Legacy fallback: old hardcoded E*B*S display. # Legacy fallback: old hardcoded E*B*S display.
obj_str = f"product(scaled) = {fe * fb * fs / 1000}" obj_str = f"product(scaled) = {fe * fb * fs / 1000}"

View file

@ -372,6 +372,18 @@
<label for="initial_C">Capital</label> <label for="initial_C">Capital</label>
<input type="number" id="initial_C" name="initial_C" value="{{ initial[3] // 10 }}" min="0"> <input type="number" id="initial_C" name="initial_C" value="{{ initial[3] // 10 }}" min="0">
</div> </div>
<div class="form-group">
<label for="initial_R">Renown</label>
<input type="number" id="initial_R" name="initial_R" value="{{ initial[4] // 10 }}" min="0">
</div>
<div class="form-group">
<label for="initial_L">Luxuries</label>
<input type="number" id="initial_L" name="initial_L" value="{{ initial[5] // 10 }}" min="0">
</div>
<div class="form-group">
<label for="initial_X">Express tickets</label>
<input type="number" id="initial_X" name="initial_X" value="{{ initial[6] // 10 }}" min="0">
</div>
</div> </div>
</div> </div>
@ -452,6 +464,21 @@
<input type="number" id="objective_factor_C" name="objective_factor_C" <input type="number" id="objective_factor_C" name="objective_factor_C"
value="{{ objective_factors.get('C', 0) }}" step="1"> value="{{ objective_factors.get('C', 0) }}" step="1">
</div> </div>
<div class="form-group">
<label for="objective_factor_R">Renown Factor</label>
<input type="number" id="objective_factor_R" name="objective_factor_R"
value="{{ objective_factors.get('R', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_L">Luxuries Factor</label>
<input type="number" id="objective_factor_L" name="objective_factor_L"
value="{{ objective_factors.get('L', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_X">Express tickets Factor</label>
<input type="number" id="objective_factor_X" name="objective_factor_X"
value="{{ objective_factors.get('X', 0) }}" step="1">
</div>
</div> </div>
</div> </div>
@ -495,7 +522,9 @@
<p style="color: #666; font-size: 13px; margin-bottom: 15px;"> <p style="color: #666; font-size: 13px; margin-bottom: 15px;">
Enforce minimum resource levels at specific steps. Examples: <code Enforce minimum resource levels at specific steps. Examples: <code
style="background: #f0f0f0; padding: 2px 4px;">E[3] >= 50</code>, <code style="background: #f0f0f0; padding: 2px 4px;">E[3] >= 50</code>, <code
style="background: #f0f0f0; padding: 2px 4px;">B[2] + S[2] >= 100</code> style="background: #f0f0f0; padding: 2px 4px;">B[2] + S[2] >= 100</code>.
Resources: E (Electrum), B (Brass), S (Steel), C (Capital), R (Renown),
L (Luxuries), X (Express tickets)
</p> </p>
<p style="color: red; font-size:13px;">Warning: all resources are multiplied by 10 internally so <p style="color: red; font-size:13px;">Warning: all resources are multiplied by 10 internally so
constraints should also be multiplied by 10 e.g. <code>E[2]&gt;=10</code> checks if electrum is constraints should also be multiplied by 10 e.g. <code>E[2]&gt;=10</code> checks if electrum is
@ -615,6 +644,10 @@
${Array.from({length: NUM_STEPS}, (_, i) => `<option value="${i + 1}">Step ${i + 1}</option>`).join('')} ${Array.from({length: NUM_STEPS}, (_, i) => `<option value="${i + 1}">Step ${i + 1}</option>`).join('')}
</select> </select>
</div> </div>
<div class="checkbox-group" style="margin-bottom: 15px;">
<input type="checkbox" id="city_${id}_base" name="city_${id}_base">
<label for="city_${id}_base" style="margin: 0;">Base (Provisioner bonus applies here)</label>
</div>
<div class="form-group"> <div class="form-group">
<label for="city_${id}_adjacent">Adjacent Cities</label> <label for="city_${id}_adjacent">Adjacent Cities</label>
<input type="text" id="city_${id}_adjacent" name="city_${id}_adjacent" placeholder="e.g., 0,2,3" class="agent-steps-input"> <input type="text" id="city_${id}_adjacent" name="city_${id}_adjacent" placeholder="e.g., 0,2,3" class="agent-steps-input">
@ -740,10 +773,16 @@
data[input.name] = input.checked; data[input.name] = input.checked;
}); });
// Convert per-city Base checkboxes to true/false
const baseCheckboxes = form.querySelectorAll('input[name^="city_"][name$="_base"][type="checkbox"]');
baseCheckboxes.forEach(input => {
data[input.name] = input.checked;
});
// Collect objective mode and factors (only nonzero factors; missing = excluded) // Collect objective mode and factors (only nonzero factors; missing = excluded)
data.objective_mode = document.getElementById('objective_mode').value; data.objective_mode = document.getElementById('objective_mode').value;
const objectiveFactors = {}; const objectiveFactors = {};
['E', 'B', 'S', 'C'].forEach(r => { ['E', 'B', 'S', 'C', 'R', 'L', 'X'].forEach(r => {
const factor = parseInt(document.getElementById(`objective_factor_${r}`).value); const factor = parseInt(document.getElementById(`objective_factor_${r}`).value);
if (factor) { if (factor) {
objectiveFactors[r] = factor; objectiveFactors[r] = factor;

View file

@ -33,8 +33,11 @@ def solve_handler():
data = request.get_json() data = request.get_json()
# Parse initial resources (divide by 10 since UI shows unscaled values) # Parse initial resources (divide by 10 since UI shows unscaled values)
# R = Renown, L = Luxuries, X = Express tickets (default 0; no sources yet)
initial_defaults = {"E": 3, "B": 3, "S": 3, "C": 3, "R": 0, "L": 0, "X": 0}
initial = tuple( initial = tuple(
int(float(data.get(f"initial_{r}", 3)) * 10) for r in ["E", "B", "S", "C"] int(float(data.get(f"initial_{r}", d)) * 10)
for r, d in initial_defaults.items()
) )
# Parse per-city arrivals and departures (dynamic number of cities) # Parse per-city arrivals and departures (dynamic number of cities)
@ -72,9 +75,15 @@ def solve_handler():
if idx.strip() if idx.strip()
] ]
# Parse base flag (checkbox-style: may arrive as bool or string)
base_val = data.get(f"city_{city_idx}_base", False)
if isinstance(base_val, str):
base_val = base_val.lower() in ("true", "on", "1", "yes")
city_data = { city_data = {
"type": city_type, "type": city_type,
"adjacent_to": adjacent_to, "adjacent_to": adjacent_to,
"base": bool(base_val),
"departure_step": departure_step, "departure_step": departure_step,
} }