giving up on this in favor of complete redo
This commit is contained in:
parent
24f58765ac
commit
d7b4bf79b1
3 changed files with 387 additions and 74 deletions
405
solve.py
405
solve.py
|
|
@ -17,11 +17,12 @@ import printer
|
|||
# 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
|
||||
# trade-good splits (Baron/Fence et al, phase B/C) can be modeled with integers.
|
||||
# Display in _report divides by 10.
|
||||
INITIAL = (30, 30, 30, 30)
|
||||
INITIAL = (30, 30, 30, 30, 0, 0, 0)
|
||||
|
||||
# fixed_choices = {
|
||||
# "actions": {
|
||||
|
|
@ -38,14 +39,15 @@ INITIAL = (30, 30, 30, 30)
|
|||
# FIXED_CHOICES = None
|
||||
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
|
||||
# (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
|
||||
RESOURCE_CONSTRAINTS = []
|
||||
|
||||
# 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
|
||||
# factors are allowed only in "sum" mode (a negative exponent would mean
|
||||
# division, which integer CP-SAT cannot express).
|
||||
|
|
@ -56,7 +58,7 @@ OBJECTIVE_MODE = "product" # "product" or "sum"
|
|||
def _validate_objective(factors, mode):
|
||||
if mode not in ("product", "sum"):
|
||||
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:
|
||||
raise ValueError(f"unknown objective_factors keys: {sorted(unknown)}")
|
||||
for k, v in factors.items():
|
||||
|
|
@ -76,8 +78,12 @@ def _validate_objective(factors, mode):
|
|||
# Arriving cities act that same step.
|
||||
# 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},
|
||||
# "departure_step": NUM_STEPS + 1}
|
||||
# `adjacent_to` is currently parsed but unused; see normalize_city().
|
||||
# "base": False, "departure_step": NUM_STEPS + 1}
|
||||
# `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 `departure_step` is the first step at which the city is ABSENT;
|
||||
# default NUM_STEPS+1 means the city stays through the whole simulation.
|
||||
|
|
@ -107,6 +113,7 @@ def normalize_city(c):
|
|||
return {
|
||||
"type": c["type"],
|
||||
"adjacent_to": c.get("adjacent_to", []),
|
||||
"base": c.get("base", False),
|
||||
"vats": vats,
|
||||
"departure_step": c.get("departure_step", NUM_STEPS + 1),
|
||||
}
|
||||
|
|
@ -141,6 +148,12 @@ AGENT_AVAILABILITY = {
|
|||
"fence": [],
|
||||
"foreman": [],
|
||||
"industrialist": [],
|
||||
"vinter": [],
|
||||
"artificer": [],
|
||||
"connoisseur": [],
|
||||
"economist": [],
|
||||
"influencer": [],
|
||||
"cosmopolitan": [],
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
|
|
@ -753,7 +766,10 @@ class EventAgent:
|
|||
|
||||
|
||||
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):
|
||||
m = model
|
||||
|
|
@ -763,11 +779,25 @@ class Capitalist(Agent):
|
|||
col = ctx["col"].get((i, t))
|
||||
if col is None:
|
||||
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("")
|
||||
m.AddMultiplicationEquality(both, [g, col])
|
||||
active = m.NewBoolVar("")
|
||||
m.AddMultiplicationEquality(active, [both, cap_yield])
|
||||
bonus = m.NewIntVar(0, 20, "")
|
||||
m.Add(bonus == 20).OnlyEnforceIf(both)
|
||||
m.Add(bonus == 0).OnlyEnforceIf(both.Not())
|
||||
m.Add(bonus == 20).OnlyEnforceIf(active)
|
||||
m.Add(bonus == 0).OnlyEnforceIf(active.Not())
|
||||
ctx["gain_C"][t].append(bonus)
|
||||
|
||||
|
||||
|
|
@ -793,41 +823,63 @@ class Baron(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):
|
||||
m = model
|
||||
g = ctx["governor"].get((i, t, self.name))
|
||||
if g is None:
|
||||
return
|
||||
hasA = ctx["hasA"]
|
||||
for u_var in (ctx["ub"].get((i, t)), ctx["ud"].get((i, t))):
|
||||
if u_var is None:
|
||||
continue
|
||||
both = m.NewBoolVar("")
|
||||
m.AddMultiplicationEquality(both, [g, u_var])
|
||||
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())
|
||||
ctx["gain_S"][t].append(refund)
|
||||
|
||||
|
||||
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):
|
||||
m = model
|
||||
g = ctx["governor"].get((i, t, self.name))
|
||||
if g is None:
|
||||
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, "")
|
||||
m.Add(bonus == 15).OnlyEnforceIf(g)
|
||||
m.Add(bonus == 0).OnlyEnforceIf(g.Not())
|
||||
m.Add(bonus == 15).OnlyEnforceIf(appoint)
|
||||
m.Add(bonus == 0).OnlyEnforceIf(appoint.Not())
|
||||
ctx["gain_E"][t].append(bonus)
|
||||
|
||||
|
||||
class Metallurgist(Agent):
|
||||
"""Foundry-only: adds +1 to vat increment. Implemented centrally in solve().
|
||||
This class exists only for registry uniformity."""
|
||||
"""As Governor of a Foundry, grants the effect of the Overflow Vats
|
||||
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):
|
||||
# Vat-level effect handled in solve()'s centralized vat-transition block.
|
||||
|
|
@ -835,8 +887,11 @@ class Metallurgist(Agent):
|
|||
|
||||
|
||||
class Builder(Agent):
|
||||
"""One-shot: grants hasD[i,t+1]=1 when governing a Foundry city.
|
||||
Fires at most once across all (i,t)."""
|
||||
"""Per appointment: grants the governed City its type-specific Upgrade.
|
||||
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):
|
||||
ctx.setdefault("builder_fire", {})
|
||||
|
|
@ -928,53 +983,156 @@ class Planner(Agent):
|
|||
|
||||
|
||||
class Foreman(Agent):
|
||||
"""TODO: Restructure ability — allows other industry actions during renovation.
|
||||
The current model uses 'exactly one action per city per step', so renovation
|
||||
already blocks no specific action separately. Foreman becomes meaningful only
|
||||
once renovation-blocks-action semantics are modeled.
|
||||
"""
|
||||
"""Restructure: as Governor, Renovating the city does not prevent other
|
||||
Industry Actions — the governed city may take one additional action in
|
||||
the same step as a Renovate. Wired centrally into the per-city action
|
||||
count constraint in solve() (action_sum == present + foreman_extra);
|
||||
this class exists only for registry uniformity."""
|
||||
|
||||
def apply(self, model, i, t, ctx):
|
||||
pass
|
||||
|
||||
|
||||
class Industrialist(Agent):
|
||||
"""Network ability — grants Infrastructure (hasA) to governed city and its
|
||||
adjacent cities when appointed governor."""
|
||||
"""Network: when appointed Governor, the governed City and all cities
|
||||
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):
|
||||
m = model
|
||||
g = ctx["governor"].get((i, t, self.name))
|
||||
if g is None:
|
||||
return
|
||||
|
||||
cities = ctx.get("cities")
|
||||
hasA = ctx.get("hasA")
|
||||
NUM_STEPS = ctx.get("NUM_STEPS", 5)
|
||||
|
||||
if cities is None or hasA is None:
|
||||
col = ctx["col"].get((i, t))
|
||||
if col is None:
|
||||
return
|
||||
|
||||
a_step, a_city = cities[i]
|
||||
adjacent_indices = a_city.get("adjacent_to", [])
|
||||
|
||||
# When Industrialist governs city i at step t, grant hasA to city i and adjacent cities at t+1
|
||||
if t + 1 <= NUM_STEPS:
|
||||
# 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)
|
||||
both = m.NewBoolVar("")
|
||||
m.AddMultiplicationEquality(both, [g, col])
|
||||
deposit = m.NewIntVar(0, 10, "")
|
||||
m.Add(deposit == 10).OnlyEnforceIf(both)
|
||||
m.Add(deposit == 0).OnlyEnforceIf(both.Not())
|
||||
ctx["tg_pool"][t].append(deposit)
|
||||
|
||||
|
||||
class Fence(EventAgent):
|
||||
"""Each available step, deposits +20 (=2 trade goods x10) into tg_pool[t]."""
|
||||
class Cosmopolitan(Agent):
|
||||
"""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):
|
||||
deposit = model.NewConstant(20)
|
||||
ctx["tg_pool"][t].append(deposit)
|
||||
ctx.setdefault("fence_deposits", {})[t] = 20
|
||||
if t != min(self.available_steps):
|
||||
return
|
||||
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
|
||||
_validate_objective(objective_factors, objective_mode)
|
||||
# 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 -----------------------------------------
|
||||
# cities: list[tuple[int, dict]] where the dict has keys "type" and
|
||||
# "adjacent_to". TODO: adjacency unused; for Industrialist agent.
|
||||
# cities: list[tuple[int, dict]] where the dict has keys "type",
|
||||
# "adjacent_to", "base", "vats", "departure_step" (see normalize_city).
|
||||
# Adjacency is consumed by the Industrialist grant wiring below.
|
||||
cities = []
|
||||
for s in range(1, NUM_STEPS + 1):
|
||||
for entry in arrivals.get(s, []):
|
||||
|
|
@ -1064,6 +1228,12 @@ def solve(
|
|||
gain_B = {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)}
|
||||
# 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_S = {t: [] for t in range(1, NUM_STEPS + 1)}
|
||||
|
||||
|
|
@ -1137,6 +1307,9 @@ def solve(
|
|||
"planner": Planner,
|
||||
"foreman": Foreman,
|
||||
"industrialist": Industrialist,
|
||||
"vinter": Vinter,
|
||||
"artificer": Artificer,
|
||||
"cosmopolitan": Cosmopolitan,
|
||||
}
|
||||
for name, cls in agent_classes.items():
|
||||
if name in AGENT_AVAILABILITY:
|
||||
|
|
@ -1144,6 +1317,9 @@ def solve(
|
|||
|
||||
event_agent_classes = {
|
||||
"fence": Fence,
|
||||
"connoisseur": Connoisseur,
|
||||
"economist": Economist,
|
||||
"influencer": Influencer,
|
||||
}
|
||||
for name, cls in event_agent_classes.items():
|
||||
if name in AGENT_AVAILABILITY:
|
||||
|
|
@ -1165,6 +1341,9 @@ def solve(
|
|||
"gain_B": gain_B,
|
||||
"gain_S": gain_S,
|
||||
"gain_C": gain_C,
|
||||
"gain_R": gain_R,
|
||||
"gain_L": gain_L,
|
||||
"gain_X": gain_X,
|
||||
"cost_C": cost_C,
|
||||
"cost_S": cost_S,
|
||||
"builder_fire": builder_fire_map,
|
||||
|
|
@ -1181,6 +1360,31 @@ def solve(
|
|||
if governor.get((city, step, agent_name)) is not None:
|
||||
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 -----------------------------------------------
|
||||
for i in range(N):
|
||||
a_step, a_city = cities[i]
|
||||
|
|
@ -1260,6 +1464,9 @@ def solve(
|
|||
"gain_B": gain_B,
|
||||
"gain_S": gain_S,
|
||||
"gain_C": gain_C,
|
||||
"gain_R": gain_R,
|
||||
"gain_L": gain_L,
|
||||
"gain_X": gain_X,
|
||||
"cost_C": cost_C,
|
||||
"cost_S": cost_S,
|
||||
"tg_pool": tg_pool,
|
||||
|
|
@ -1319,12 +1526,25 @@ def solve(
|
|||
if r is not None:
|
||||
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.selector_var(i, t, ctx)
|
||||
for action in action_registry
|
||||
if action.selector_var(i, t, ctx) is not None
|
||||
)
|
||||
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
|
||||
|
|
@ -1334,9 +1554,11 @@ def solve(
|
|||
|
||||
# Upgrade transitions (skip propagation past last present step)
|
||||
if t + 1 < d_step:
|
||||
m.AddMaxEquality(
|
||||
hasA[i, t + 1], [hasA[i, t], ua.get((i, t), m.NewConstant(0))]
|
||||
)
|
||||
# Industrialist grants are extra OR-sources for hasA, exactly
|
||||
# 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(
|
||||
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))
|
||||
_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, "")
|
||||
metallurgist_g = governor.get((i, t, "metallurgist"))
|
||||
if metallurgist_g is not None and "metallurgist" in AGENT_AVAILABILITY:
|
||||
|
|
@ -1395,7 +1621,9 @@ def solve(
|
|||
m.AddMultiplicationEquality(
|
||||
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:
|
||||
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(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) ----
|
||||
for t in range(1, NUM_STEPS + 1):
|
||||
ctx_global = {"N": N, "ow": ow, "t": t}
|
||||
|
|
@ -1471,6 +1695,9 @@ def solve(
|
|||
"gain_B": gain_B,
|
||||
"gain_S": gain_S,
|
||||
"gain_C": gain_C,
|
||||
"gain_R": gain_R,
|
||||
"gain_L": gain_L,
|
||||
"gain_X": gain_X,
|
||||
"cost_C": cost_C,
|
||||
"cost_S": cost_S,
|
||||
"fence_deposits": fence_deposits,
|
||||
|
|
@ -1481,11 +1708,11 @@ def solve(
|
|||
if t in agent.available_steps:
|
||||
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
|
||||
# IntVars into tg_pool[t]; here we declare a 4-way split into the
|
||||
# resource gain accumulators. If the pool is empty for step t, the split
|
||||
# vars are constrained to 0.
|
||||
# IntVars into tg_pool[t]; here we declare a 5-way split into the
|
||||
# resource gain accumulators (Luxuries can only be gained this way).
|
||||
# If the pool is empty for step t, the split vars are constrained to 0.
|
||||
for t in range(1, NUM_STEPS + 1):
|
||||
pool_total = m.NewIntVar(0, max_res, f"tg_total_{t}")
|
||||
if tg_pool[t]:
|
||||
|
|
@ -1496,32 +1723,44 @@ def solve(
|
|||
tg_B = m.NewIntVar(0, max_res, f"tg_B_{t}")
|
||||
tg_S = m.NewIntVar(0, max_res, f"tg_S_{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
|
||||
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_S, 10)
|
||||
m.AddModuloEquality(m.NewIntVar(0, 0, ""), tg_L, 10)
|
||||
gain_E[t].append(tg_E)
|
||||
gain_B[t].append(tg_B)
|
||||
gain_S[t].append(tg_S)
|
||||
gain_C[t].append(tg_C)
|
||||
gain_L[t].append(tg_L)
|
||||
|
||||
# ---- resource pool recursion --------------------------------------
|
||||
E = {1: m.NewIntVar(initial[0], initial[0], "E1")}
|
||||
B = {1: m.NewIntVar(initial[1], initial[1], "B1")}
|
||||
S = {1: m.NewIntVar(initial[2], initial[2], "S1")}
|
||||
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):
|
||||
E[t + 1] = m.NewIntVar(0, max_res, f"E_{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}")
|
||||
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(S[t] - sum(cost_S[t]) >= 0)
|
||||
m.Add(E[t + 1] == E[t] + sum(gain_E[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(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]
|
||||
|
||||
|
|
@ -1537,6 +1776,9 @@ def solve(
|
|||
"B": B,
|
||||
"S": S,
|
||||
"C": C,
|
||||
"R": R,
|
||||
"L": L,
|
||||
"X": X,
|
||||
}
|
||||
for i, constraint_fn in enumerate(resource_constraints):
|
||||
constraint = constraint_fn(res_dict)
|
||||
|
|
@ -1560,7 +1802,15 @@ def solve(
|
|||
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
|
||||
# ceiling solve costs up to 20s, and only objective resources need
|
||||
|
|
@ -1651,6 +1901,9 @@ def solve(
|
|||
B,
|
||||
S,
|
||||
C,
|
||||
R,
|
||||
L,
|
||||
X,
|
||||
finalE,
|
||||
finalB,
|
||||
finalS,
|
||||
|
|
@ -1707,6 +1960,9 @@ def _report(
|
|||
B,
|
||||
S,
|
||||
C,
|
||||
R,
|
||||
L,
|
||||
X,
|
||||
finalE,
|
||||
finalB,
|
||||
finalS,
|
||||
|
|
@ -1804,14 +2060,15 @@ def _report(
|
|||
return "(inactive)"
|
||||
|
||||
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):
|
||||
label = f"after5" if t == NUM_STEPS + 1 else f"start {t}"
|
||||
c_val = solver.Value(C[t])
|
||||
c_str = f"{c_val / 10:6.1f}"
|
||||
print(
|
||||
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:
|
||||
|
|
@ -1872,7 +2129,15 @@ def _report(
|
|||
)
|
||||
|
||||
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:
|
||||
# Legacy fallback: old hardcoded E*B*S display.
|
||||
obj_str = f"product(scaled) = {fe * fb * fs / 1000}"
|
||||
|
|
|
|||
|
|
@ -372,6 +372,18 @@
|
|||
<label for="initial_C">Capital</label>
|
||||
<input type="number" id="initial_C" name="initial_C" value="{{ initial[3] // 10 }}" min="0">
|
||||
</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>
|
||||
|
||||
|
|
@ -452,6 +464,21 @@
|
|||
<input type="number" id="objective_factor_C" name="objective_factor_C"
|
||||
value="{{ objective_factors.get('C', 0) }}" step="1">
|
||||
</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>
|
||||
|
||||
|
|
@ -495,7 +522,9 @@
|
|||
<p style="color: #666; font-size: 13px; margin-bottom: 15px;">
|
||||
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;">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 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]>=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('')}
|
||||
</select>
|
||||
</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">
|
||||
<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">
|
||||
|
|
@ -740,10 +773,16 @@
|
|||
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)
|
||||
data.objective_mode = document.getElementById('objective_mode').value;
|
||||
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);
|
||||
if (factor) {
|
||||
objectiveFactors[r] = factor;
|
||||
|
|
|
|||
11
web_solve.py
11
web_solve.py
|
|
@ -33,8 +33,11 @@ def solve_handler():
|
|||
data = request.get_json()
|
||||
|
||||
# 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(
|
||||
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)
|
||||
|
|
@ -72,9 +75,15 @@ def solve_handler():
|
|||
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 = {
|
||||
"type": city_type,
|
||||
"adjacent_to": adjacent_to,
|
||||
"base": bool(base_val),
|
||||
"departure_step": departure_step,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue