From d7b4bf79b17fe63b8f0a02cd2e28c30c6d896428 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Wed, 17 Jun 2026 16:29:49 -0400 Subject: [PATCH] giving up on this in favor of complete redo --- solve.py | 407 ++++++++++++++++++++++++++++++++++-------- templates/solver.html | 43 ++++- web_solve.py | 11 +- 3 files changed, 387 insertions(+), 74 deletions(-) diff --git a/solve.py b/solve.py index 6c0508c..64cefbe 100644 --- a/solve.py +++ b/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,13 +1526,26 @@ 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 ) - 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 # 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) 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}" diff --git a/templates/solver.html b/templates/solver.html index 5099dee..58fccb9 100644 --- a/templates/solver.html +++ b/templates/solver.html @@ -372,6 +372,18 @@ +
+ + +
+
+ + +
+
+ + +
@@ -452,6 +464,21 @@ +
+ + +
+
+ + +
+
+ + +
@@ -495,7 +522,9 @@

Enforce minimum resource levels at specific steps. Examples: E[3] >= 50, B[2] + S[2] >= 100 + style="background: #f0f0f0; padding: 2px 4px;">B[2] + S[2] >= 100. + Resources: E (Electrum), B (Brass), S (Steel), C (Capital), R (Renown), + L (Luxuries), X (Express tickets)

Warning: all resources are multiplied by 10 internally so constraints should also be multiplied by 10 e.g. E[2]>=10 checks if electrum is @@ -615,6 +644,10 @@ ${Array.from({length: NUM_STEPS}, (_, i) => ``).join('')} +

+ + +
@@ -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; diff --git a/web_solve.py b/web_solve.py index 7267171..98b4b4f 100644 --- a/web_solve.py +++ b/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, }