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
407
solve.py
407
solve.py
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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]>=10</code> checks if electrum is
|
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('')}
|
${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;
|
||||||
|
|
|
||||||
11
web_solve.py
11
web_solve.py
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue