Compare commits

...

10 commits

Author SHA1 Message Date
Pagwin
4f9a980cfb
safety bars 2026-06-09 16:59:58 -04:00
Pagwin
0900bbaabd
accidental format + added a warning 2026-06-09 16:58:56 -04:00
Pagwin
17020b857c
added resource constraints 2026-06-09 16:53:35 -04:00
Pagwin
3e604324ad
web server with docker container 2026-06-09 15:26:51 -04:00
Pagwin
b1dba083a0
starting on a web ui 2026-06-09 13:33:39 -04:00
Pagwin
041bf9febc
allowing fixed moves 2026-06-09 13:32:55 -04:00
Pagwin
7229124bb5
stuff 2026-06-09 12:57:36 -04:00
Pagwin
9711aa5bac
... 2026-06-08 15:02:45 -04:00
Pagwin
d17ffbdb45
moved main-2 to main 2026-06-08 14:34:45 -04:00
Pagwin
8bb6877e1b
main-2 done 2026-06-08 14:34:27 -04:00
10 changed files with 3065 additions and 728 deletions

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM python:3.13-slim
WORKDIR /app
# Install uv
RUN pip install --no-cache-dir uv
# Copy project files
COPY pyproject.toml uv.lock ./
# Install dependencies with uv
RUN uv sync --no-editable
# Copy source code
COPY solve.py web_solve.py ./
COPY templates/ templates/
# Set environment variables
ENV FLASK_APP=web_solve.py
ENV PYTHONUNBUFFERED=1
# Run Flask app
CMD ["uv", "run", "python", "web_solve.py"]

25
constraint_validator.py Normal file
View file

@ -0,0 +1,25 @@
"""Create constraint lambdas from expressions."""
import re
def make_constraint_lambda(expr_str):
"""Create a lambda function from a constraint expression.
Transforms resource identifiers to res dict access and creates a lambda
with restricted builtins.
Args:
expr_str: Constraint expression like "E[3] >= 50" or "B[2] + S[2] >= 100"
Returns:
A lambda function: lambda res: <evaluated_expression>
Raises:
Exception: If expression fails to eval
"""
# Transform identifiers to res subscript access: E -> res["E"], B -> res["B"], etc.
expr = re.sub(r'\b([A-Z])\b', r'res["\1"]', expr_str)
# Create lambda with restricted builtins (no imports, no dangerous functions)
return eval(f"lambda res: {expr}", {"__builtins__": {}})

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
version: '3.8'
services:
web:
build: .
ports:
- "5000:5000"
environment:
- FLASK_ENV=development
- FLASK_DEBUG=1
volumes:
- .:/app
command: uv run python web_solve.py

725
main.py
View file

@ -1,725 +0,0 @@
"""
City Resource Optimization -> CP-SAT (Google OR-Tools)
Maximise Electrum * Brass * Steel after the gains of step 5.
The objective is a product of three variables, so this is a constraint-
programming / nonlinear problem, hence CP-SAT (cp_model) rather than the
LP/MIP solver. Read the comments at:
- PARAMETERS (initial pools + arrival schedule + bonus mode)
- "OBJECTIVE IS SET HERE" (the product being maximised -- tweak freely)
"""
from ortools.sat.python import cp_model
import printer
# ======================================================================
# PARAMETERS -- edit these
# ======================================================================
# Starting resource pools at the start of step 1: (Electrum, Brass, Steel, Capital)
INITIAL = (3, 3, 3, 3)
# Arrival schedule. Key = step (1..5), value = list of city types that arrive
# at the START of that step. Types: 'H' Hub, 'F' Foundry, 'M' Metropolis, 'N' Monument.
# Total cities across all steps must be <= 7. Arriving cities act that same step.
ARRIVALS = {
1: ["H", "F", "H", "H", "N"],
2: [],
3: [],
4: [],
5: [],
}
# Collect Bonus (b) adds +1 to whatever a Collect gives. On a foundry that is
# +1 of the resource collected (i.e. the chosen vat's value + 1). This is the
# same uniform rule as Hub (+1 Capital) and Metropolis (+1 resource pick).
NUM_STEPS = 5
MAX_RES = (
200 # upper bound on any resource pool (raise if you expect more; affects speed)
)
MAX_VAT = 15 # upper bound on a foundry vat value (1 + 2*nsteps is plenty)
# ======================================================================
# MODEL
# ======================================================================
def solve(
initial=INITIAL,
arrivals=ARRIVALS,
max_res=MAX_RES,
max_vat=MAX_VAT,
time_limit=60.0,
num_workers=8,
verbose=True,
):
# ---- build the city list -----------------------------------------
cities = [] # list of (arrival_step, arrival_type)
for s in range(1, NUM_STEPS + 1):
for typ in arrivals.get(s, []):
cities.append((s, typ))
N = len(cities)
# assert N <= 7, f"At most 7 cities allowed, got {N}"
m = cp_model.CpModel()
def AND(a, b):
"""Boolean AND of two 0/1 vars, returned as a new 0/1 var."""
c = m.NewBoolVar("")
m.AddMultiplicationEquality(c, [a, b])
return c
# ---- state variables ---------------------------------------------
# Indexed [i][t]; t in 1..NUM_STEPS+1 for state (t=NUM_STEPS+1 == "after step 5").
isH, isF, isM, isMon, present = {}, {}, {}, {}, {}
hasA, hasB, hasD = {}, {}, {}
vE, vB, vS = {}, {}, {} # foundry vats at start of step t
# action variables, t in 1..NUM_STEPS
col, ua, ub, ud = {}, {}, {}, {} # collect / upgrade a,b,d
ow = {} # overwork (global limit: 1 per step)
rH, rF, rM = {}, {}, {} # renovate -> Hub/Foundry/Metro
cvE, cvB, cvS = {}, {}, {} # foundry: which vat collected (normal collect)
owcvE, owcvB, owcvS = {}, {}, {} # foundry: which vat collected (overwork)
mE, mB, mS, mC = {}, {}, {}, {} # metropolis: resources picked (+1 each)
owmE, owmB, owmS, owmC = {}, {}, {}, {} # metropolis: resources picked (overwork)
for i in range(N):
a_step, a_type = cities[i]
for t in range(1, NUM_STEPS + 2):
isH[i, t] = m.NewBoolVar(f"isH_{i}_{t}")
isF[i, t] = m.NewBoolVar(f"isF_{i}_{t}")
isM[i, t] = m.NewBoolVar(f"isM_{i}_{t}")
isMon[i, t] = m.NewBoolVar(f"isMon_{i}_{t}")
present[i, t] = m.NewBoolVar(f"present_{i}_{t}")
hasA[i, t] = m.NewBoolVar(f"hasA_{i}_{t}")
hasB[i, t] = m.NewBoolVar(f"hasB_{i}_{t}")
hasD[i, t] = m.NewBoolVar(f"hasD_{i}_{t}")
vE[i, t] = m.NewIntVar(0, max_vat, f"vE_{i}_{t}")
vB[i, t] = m.NewIntVar(0, max_vat, f"vB_{i}_{t}")
vS[i, t] = m.NewIntVar(0, max_vat, f"vS_{i}_{t}")
# presence: present from arrival step onward, persists
m.Add(present[i, t] == (1 if t >= a_step else 0))
# exactly one type iff present
m.Add(isH[i, t] + isF[i, t] + isM[i, t] + isMon[i, t] == present[i, t])
for t in range(1, NUM_STEPS + 1):
col[i, t] = m.NewBoolVar(f"col_{i}_{t}")
ua[i, t] = m.NewBoolVar(f"ua_{i}_{t}")
ub[i, t] = m.NewBoolVar(f"ub_{i}_{t}")
ud[i, t] = m.NewBoolVar(f"ud_{i}_{t}")
ow[i, t] = m.NewBoolVar(f"ow_{i}_{t}")
rH[i, t] = m.NewBoolVar(f"rH_{i}_{t}")
rF[i, t] = m.NewBoolVar(f"rF_{i}_{t}")
rM[i, t] = m.NewBoolVar(f"rM_{i}_{t}")
cvE[i, t] = m.NewBoolVar(f"cvE_{i}_{t}")
cvB[i, t] = m.NewBoolVar(f"cvB_{i}_{t}")
cvS[i, t] = m.NewBoolVar(f"cvS_{i}_{t}")
owcvE[i, t] = m.NewBoolVar(f"owcvE_{i}_{t}")
owcvB[i, t] = m.NewBoolVar(f"owcvB_{i}_{t}")
owcvS[i, t] = m.NewBoolVar(f"owcvS_{i}_{t}")
mE[i, t] = m.NewIntVar(0, 3, f"mE_{i}_{t}")
mB[i, t] = m.NewIntVar(0, 3, f"mB_{i}_{t}")
mS[i, t] = m.NewIntVar(0, 3, f"mS_{i}_{t}")
mC[i, t] = m.NewIntVar(0, 3, f"mC_{i}_{t}")
owmE[i, t] = m.NewIntVar(0, 6, f"owmE_{i}_{t}")
owmB[i, t] = m.NewIntVar(0, 6, f"owmB_{i}_{t}")
owmS[i, t] = m.NewIntVar(0, 6, f"owmS_{i}_{t}")
owmC[i, t] = m.NewIntVar(0, 6, f"owmC_{i}_{t}")
# ---- per-step gain/cost accumulators (linear expressions) ---------
gain_E = {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_C = {t: [] for t in range(1, NUM_STEPS + 1)}
cost_C = {t: [] for t in range(1, NUM_STEPS + 1)} # capital cost (collects)
cost_S = {t: [] for t in range(1, NUM_STEPS + 1)} # steel cost (upgrades b,d)
# ---- per-city logic -----------------------------------------------
for i in range(N):
a_step, a_type = cities[i]
# initial type at arrival step
init = {"H": isH, "F": isF, "M": isM, "N": isMon}
m.Add(init[a_type][i, a_step] == 1)
# no upgrades at arrival
m.Add(hasA[i, a_step] == 0)
m.Add(hasB[i, a_step] == 0)
m.Add(hasD[i, a_step] == 0)
# vats at arrival
m.Add(vE[i, a_step] == 1)
m.Add(vB[i, a_step] == 1)
m.Add(vS[i, a_step] == 1)
# before arrival: everything zero
for t in range(1, a_step):
for v in (isH, isF, isM, isMon, hasA, hasB, hasD, vE, vB, vS):
m.Add(v[i, t] == 0)
if t <= NUM_STEPS:
for v in (
col,
ua,
ub,
ud,
ow,
rH,
rF,
rM,
cvE,
cvB,
cvS,
owcvE,
owcvB,
owcvS,
mE,
mB,
mS,
mC,
owmE,
owmB,
owmS,
owmC,
):
m.Add(v[i, t] == 0)
# action + transition logic for active steps
for t in range(a_step, NUM_STEPS + 1):
P = present[i, t]
ren = m.NewBoolVar("") # any renovation this step
m.Add(ren == rH[i, t] + rF[i, t] + rM[i, t])
no_ren = m.NewBoolVar("")
m.Add(no_ren == 1 - ren)
# exactly one action while present
m.Add(
col[i, t]
+ ua[i, t]
+ ub[i, t]
+ ud[i, t]
+ ow[i, t]
+ rH[i, t]
+ rF[i, t]
+ rM[i, t]
== P
)
# renovation must change type (renovate-to-X requires not-X now)
m.Add(isH[i, t] == 0).OnlyEnforceIf(rH[i, t])
m.Add(isF[i, t] == 0).OnlyEnforceIf(rF[i, t])
m.Add(isM[i, t] == 0).OnlyEnforceIf(rM[i, t])
# upgrade legality
m.Add(hasA[i, t] == 0).OnlyEnforceIf(ua[i, t]) # can't re-acquire
m.Add(hasB[i, t] == 0).OnlyEnforceIf(ub[i, t])
m.Add(hasD[i, t] == 0).OnlyEnforceIf(ud[i, t])
m.Add(isF[i, t] == 1).OnlyEnforceIf(ud[i, t]) # d is foundry-only
# LLM allowed renovation into metropolis, need to prevent that now
m.Add(rM[i, t] == 0)
# Monument constraints due to maximizing resources and not wanting enemy to get free upgrades
m.Add(col[i, t] == 0).OnlyEnforceIf(isMon[i, t])
m.Add(ua[i, t] == 0).OnlyEnforceIf(isMon[i, t])
m.Add(ub[i, t] == 0).OnlyEnforceIf(isMon[i, t])
m.Add(ud[i, t] == 0).OnlyEnforceIf(isMon[i, t])
m.Add(ow[i, t] == 0).OnlyEnforceIf(isMon[i, t])
# overwork cooldown: city that overworked last step can't collect or overwork
if t > a_step:
m.Add(col[i, t] == 0).OnlyEnforceIf(ow[i, t - 1])
m.Add(ow[i, t] == 0).OnlyEnforceIf(ow[i, t - 1])
# ---- type transition t -> t+1 ----
m.Add(isH[i, t + 1] == isH[i, t]).OnlyEnforceIf(no_ren)
m.Add(isF[i, t + 1] == isF[i, t]).OnlyEnforceIf(no_ren)
m.Add(isM[i, t + 1] == isM[i, t]).OnlyEnforceIf(no_ren)
for r, target in ((rH[i, t], isH), (rF[i, t], isF), (rM[i, t], isM)):
m.Add(target[i, t + 1] == 1).OnlyEnforceIf(r)
m.Add(isF[i, t + 1] == 0).OnlyEnforceIf(rH[i, t])
m.Add(isM[i, t + 1] == 0).OnlyEnforceIf(rH[i, t])
m.Add(isH[i, t + 1] == 0).OnlyEnforceIf(rF[i, t])
m.Add(isM[i, t + 1] == 0).OnlyEnforceIf(rF[i, t])
m.Add(isH[i, t + 1] == 0).OnlyEnforceIf(rM[i, t])
m.Add(isF[i, t + 1] == 0).OnlyEnforceIf(rM[i, t])
# ---- upgrade transition t -> t+1 ----
# a, b survive renovation and are monotone
m.AddMaxEquality(hasA[i, t + 1], [hasA[i, t], ua[i, t]])
m.AddMaxEquality(hasB[i, t + 1], [hasB[i, t], ub[i, t]])
# d is stripped on renovation, else monotone
m.Add(hasD[i, t + 1] == 0).OnlyEnforceIf(ren)
d_keep = m.NewBoolVar("")
m.AddMaxEquality(d_keep, [hasD[i, t], ud[i, t]])
m.Add(hasD[i, t + 1] == d_keep).OnlyEnforceIf(no_ren)
# ---- collect sub-choices ----
fcol = AND(isF[i, t], col[i, t])
mcol = AND(isM[i, t], col[i, t])
hcol = AND(isH[i, t], col[i, t])
# foundry: exactly one vat chosen iff foundry collects
m.Add(cvE[i, t] + cvB[i, t] + cvS[i, t] == fcol)
# metropolis: pick (2 + hasB) resources (+1 each); else nothing
npick = m.NewIntVar(0, 3, "")
m.Add(npick == 2 + hasB[i, t]).OnlyEnforceIf(mcol)
m.Add(npick == 0).OnlyEnforceIf(mcol.Not())
m.Add(mE[i, t] + mB[i, t] + mS[i, t] + mC[i, t] == npick)
# ================= COSTS =================
# foundry & metropolis collect each cost 1 Capital
cost_C[t].append(fcol)
cost_C[t].append(mcol)
# upgrades b,d cost 2 Steel, reduced by 1 if cost-reduction (a) already held
for upg in (ub[i, t], ud[i, t]):
c = m.NewIntVar(0, 2, "")
both = AND(upg, hasA[i, t])
m.Add(c == 2).OnlyEnforceIf(upg, hasA[i, t].Not())
m.Add(c == 1).OnlyEnforceIf(both)
m.Add(c == 0).OnlyEnforceIf(upg.Not())
cost_S[t].append(c)
# ================= GAINS =================
# Hub collect: +2 Capital (+1 more if collect-bonus b)
hub_gain = m.NewIntVar(0, 3, "")
m.Add(hub_gain == 2 + hasB[i, t]).OnlyEnforceIf(hcol)
m.Add(hub_gain == 0).OnlyEnforceIf(hcol.Not())
gain_C[t].append(hub_gain)
# Metropolis collect: +1 per pick
gain_E[t].append(mE[i, t])
gain_B[t].append(mB[i, t])
gain_S[t].append(mS[i, t])
gain_C[t].append(mC[i, t])
# Foundry collect: gain chosen vat's value as that resource
gEf = m.NewIntVar(0, max_vat, "")
gBf = m.NewIntVar(0, max_vat, "")
gSf = m.NewIntVar(0, max_vat, "")
m.AddMultiplicationEquality(gEf, [cvE[i, t], vE[i, t]])
m.AddMultiplicationEquality(gBf, [cvB[i, t], vB[i, t]])
m.AddMultiplicationEquality(gSf, [cvS[i, t], vS[i, t]])
gain_E[t].append(gEf)
gain_B[t].append(gBf)
gain_S[t].append(gSf)
# Collect Bonus (b): adds +1 to the amount a Collect gives. For a
# foundry that means +1 of the collected resource (vat value + 1),
# the same uniform "+1 to what Collect gives" rule as Hub/Metro.
gain_E[t].append(AND(cvE[i, t], hasB[i, t]))
gain_B[t].append(AND(cvB[i, t], hasB[i, t]))
gain_S[t].append(AND(cvS[i, t], hasB[i, t]))
# ---- overwork sub-choices (double-collect; no Capital cost) ----
fow = AND(isF[i, t], ow[i, t])
mow = AND(isM[i, t], ow[i, t])
how = AND(isH[i, t], ow[i, t])
# foundry overwork: exactly one vat chosen iff foundry overworks
m.Add(owcvE[i, t] + owcvB[i, t] + owcvS[i, t] == fow)
# metropolis overwork: pick 2*(2 + hasB) resources; else nothing
ow_npick = m.NewIntVar(0, 6, "")
m.Add(ow_npick == 2 * (2 + hasB[i, t])).OnlyEnforceIf(mow)
m.Add(ow_npick == 0).OnlyEnforceIf(mow.Not())
m.Add(owmE[i, t] + owmB[i, t] + owmS[i, t] + owmC[i, t] == ow_npick)
# ================= OVERWORK GAINS (2x normal collect) =================
# Hub overwork: 2*(2 + hasB) Capital
hub_ow_gain = m.NewIntVar(0, 6, "")
m.Add(hub_ow_gain == 2 * (2 + hasB[i, t])).OnlyEnforceIf(how)
m.Add(hub_ow_gain == 0).OnlyEnforceIf(how.Not())
gain_C[t].append(hub_ow_gain)
# Metropolis overwork: +1 per pick (picks are already doubled via ow_npick)
gain_E[t].append(owmE[i, t])
gain_B[t].append(owmB[i, t])
gain_S[t].append(owmS[i, t])
gain_C[t].append(owmC[i, t])
# Foundry overwork: 2 * chosen vat's value
owgEf = m.NewIntVar(0, 2 * max_vat, "")
owgBf = m.NewIntVar(0, 2 * max_vat, "")
owgSf = m.NewIntVar(0, 2 * max_vat, "")
_owE = m.NewIntVar(0, max_vat, "")
_owB = m.NewIntVar(0, max_vat, "")
_owS = m.NewIntVar(0, max_vat, "")
m.AddMultiplicationEquality(_owE, [owcvE[i, t], vE[i, t]])
m.AddMultiplicationEquality(_owB, [owcvB[i, t], vB[i, t]])
m.AddMultiplicationEquality(_owS, [owcvS[i, t], vS[i, t]])
m.Add(owgEf == 2 * _owE)
m.Add(owgBf == 2 * _owB)
m.Add(owgSf == 2 * _owS)
gain_E[t].append(owgEf)
gain_B[t].append(owgBf)
gain_S[t].append(owgSf)
# Collect Bonus (b) for foundry overwork: 2 * (+1) = +2 of that resource
ow_be = AND(owcvE[i, t], hasB[i, t])
ow_bb = AND(owcvB[i, t], hasB[i, t])
ow_bs = AND(owcvS[i, t], hasB[i, t])
gain_E[t].append(ow_be)
gain_E[t].append(ow_be) # added twice == *2
gain_B[t].append(ow_bb)
gain_B[t].append(ow_bb)
gain_S[t].append(ow_bs)
gain_S[t].append(ow_bs)
# ---- vat update producing vat[i, t+1] ----
# increment added to the two non-collected vats (1, or 2 with upgrade d)
inc = m.NewIntVar(1, 2, "")
m.Add(inc == 1 + hasD[i, t])
# vat_next = result of this step's action (only meaningful if foundry)
vEn = m.NewIntVar(0, max_vat, "")
vBn = m.NewIntVar(0, max_vat, "")
vSn = m.NewIntVar(0, max_vat, "")
# collect E: E->0, B,S += inc
m.Add(vEn == 0).OnlyEnforceIf(cvE[i, t])
m.Add(vBn == vB[i, t] + inc).OnlyEnforceIf(cvE[i, t])
m.Add(vSn == vS[i, t] + inc).OnlyEnforceIf(cvE[i, t])
# collect B
m.Add(vBn == 0).OnlyEnforceIf(cvB[i, t])
m.Add(vEn == vE[i, t] + inc).OnlyEnforceIf(cvB[i, t])
m.Add(vSn == vS[i, t] + inc).OnlyEnforceIf(cvB[i, t])
# collect S
m.Add(vSn == 0).OnlyEnforceIf(cvS[i, t])
m.Add(vEn == vE[i, t] + inc).OnlyEnforceIf(cvS[i, t])
m.Add(vBn == vB[i, t] + inc).OnlyEnforceIf(cvS[i, t])
# overwork vat transitions: same reset/increment as normal collect
m.Add(vEn == 0).OnlyEnforceIf(owcvE[i, t])
m.Add(vBn == vB[i, t] + inc).OnlyEnforceIf(owcvE[i, t])
m.Add(vSn == vS[i, t] + inc).OnlyEnforceIf(owcvE[i, t])
m.Add(vBn == 0).OnlyEnforceIf(owcvB[i, t])
m.Add(vEn == vE[i, t] + inc).OnlyEnforceIf(owcvB[i, t])
m.Add(vSn == vS[i, t] + inc).OnlyEnforceIf(owcvB[i, t])
m.Add(vSn == 0).OnlyEnforceIf(owcvS[i, t])
m.Add(vEn == vE[i, t] + inc).OnlyEnforceIf(owcvS[i, t])
m.Add(vBn == vB[i, t] + inc).OnlyEnforceIf(owcvS[i, t])
# foundry but neither collecting nor overworking: vats unchanged
# f_noncollect_noow = isF AND NOT fcol AND NOT fow = isF - fcol - fow
f_noncollect_noow = m.NewBoolVar("")
m.Add(f_noncollect_noow == isF[i, t] - fcol - fow)
m.Add(vEn == vE[i, t]).OnlyEnforceIf(f_noncollect_noow)
m.Add(vBn == vB[i, t]).OnlyEnforceIf(f_noncollect_noow)
m.Add(vSn == vS[i, t]).OnlyEnforceIf(f_noncollect_noow)
# assign vat[i, t+1]:
# renovate-to-foundry -> reset to 1
# continuing foundry -> vat_next
# otherwise (not foundry next) -> 0
cont_F = AND(isF[i, t], no_ren) # stays a foundry next step
for vnext, vn in (
(vE[i, t + 1], vEn),
(vB[i, t + 1], vBn),
(vS[i, t + 1], vSn),
):
m.Add(vnext == 1).OnlyEnforceIf(rF[i, t])
m.Add(vnext == vn).OnlyEnforceIf(cont_F)
m.Add(isF[i, t + 1] == 0).OnlyEnforceIf(
rF[i, t].Not(), cont_F.Not()
) # tautology guard
not_F_next = isF[i, t + 1].Not()
m.Add(vE[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)
# ---- global overwork limit: at most 1 city overworks per step ----
for t in range(1, NUM_STEPS + 1):
m.Add(sum(ow[i, t] for i in range(N)) <= 1)
# ---- 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")}
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}")
# costs are paid from the START-of-step pool (must work out before gains)
m.Add(C[t] - sum(cost_C[t]) >= 0)
m.Add(S[t] - sum(cost_S[t]) >= 0)
# next pool = start - costs + gains
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]))
finalE, finalB, finalS = E[NUM_STEPS + 1], B[NUM_STEPS + 1], S[NUM_STEPS + 1]
# ---- Phase 1: real per-resource ceilings (fast LINEAR maximisations) ----
# The triple product E*B*S has a very loose relaxation, so proving optimality
# directly is slow. We first find the true max each resource can reach on its
# own (a linear objective, solved to optimality quickly), then clamp the final
# pools to those ceilings. This tightens the product's propagation enough to
# prove optimality, and is valid because no feasible solution can exceed an
# individual resource's standalone maximum.
def _ceiling(var):
s = cp_model.CpSolver()
s.parameters.max_time_in_seconds = 20.0
s.parameters.num_search_workers = num_workers
m.Maximize(var)
st = s.Solve(m)
return (
int(s.ObjectiveValue())
if st in (cp_model.OPTIMAL, cp_model.FEASIBLE)
else max_res
)
capE = _ceiling(finalE)
capB = _ceiling(finalB)
capS = _ceiling(finalS)
m.Add(finalE <= capE)
m.Add(finalB <= capB)
m.Add(finalS <= capS)
# ======================================================================
# OBJECTIVE IS SET HERE -- maximise Electrum * Brass * Steel (post step 5)
# To change the objective, edit the three "final" pools and/or the product
# below. (finalE/finalB/finalS are the pools after step 5's gains.)
# ======================================================================
## NOTE: product can be changed here
# prodEB = m.NewIntVar(0, capE * capB, "prodEB")
# m.AddMultiplicationEquality(prodEB, [finalE, finalB])
# obj = m.NewIntVar(0, capE * capB * capS, "obj")
# m.AddMultiplicationEquality(obj, [prodEB, finalS])
# m.Maximize(obj)
# Linear objective instead (it sucks)
# m.Maximize(finalE + finalB + finalS)
# New Product
def Eprod(v):
return v * v
def Bprod(v):
return v
def Sprod(v):
return v * v
prodEE = m.NewIntVar(0, Eprod(capE), "prodEE")
m.AddMultiplicationEquality(prodEE, [finalE])
prodSS = m.NewIntVar(0, Sprod(capS), "prodSS")
m.AddMultiplicationEquality(prodSS, [finalS])
prodBB = m.NewIntVar(0, Bprod(capB), "prodBB")
m.AddMultiplicationEquality(prodBB, [finalB])
prodEB = m.NewIntVar(0, Eprod(capE) * Bprod(capB), "prodEB")
m.AddMultiplicationEquality(prodEB, [prodEE, prodBB])
obj = m.NewIntVar(0, Eprod(capE) * Bprod(capB) * Sprod(capS), "obj")
m.AddMultiplicationEquality(obj, [prodEB, prodSS])
m.Maximize(obj)
# ---- Phase 2: solve the product to optimality ----
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = time_limit
solver.parameters.num_search_workers = num_workers
status = solver.Solve(
m,
printer.IntermediateSolutionPrinter(
{"electrum": finalE, "brass": finalB, "steel": finalS}
),
)
if verbose:
print(f"(resource ceilings used: E<={capE} B<={capB} S<={capS})")
_report(
solver,
status,
cities,
N,
isH,
isF,
isM,
isMon,
col,
ua,
ub,
ud,
ow,
rH,
rF,
rM,
cvE,
cvB,
cvS,
owcvE,
owcvB,
owcvS,
mE,
mB,
mS,
mC,
owmE,
owmB,
owmS,
owmC,
hasA,
hasB,
hasD,
vE,
vB,
vS,
E,
B,
S,
C,
finalE,
finalB,
finalS,
)
return solver, status
def _report(
solver,
status,
cities,
N,
isH,
isF,
isM,
isMon,
col,
ua,
ub,
ud,
ow,
rH,
rF,
rM,
cvE,
cvB,
cvS,
owcvE,
owcvB,
owcvS,
mE,
mB,
mS,
mC,
owmE,
owmB,
owmS,
owmC,
hasA,
hasB,
hasD,
vE,
vB,
vS,
E,
B,
S,
C,
finalE,
finalB,
finalS,
):
print("status:", solver.StatusName(status))
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
return
typ_name = {}
for i in range(N):
for t in range(1, NUM_STEPS + 1):
if solver.Value(isH[i, t]):
typ_name[i, t] = "Hub"
elif solver.Value(isF[i, t]):
typ_name[i, t] = "Foundry"
elif solver.Value(isM[i, t]):
typ_name[i, t] = "Metro"
elif solver.Value(isMon[i, t]):
typ_name[i, t] = "Monument"
else:
typ_name[i, t] = "-"
def action_str(i, t):
if solver.Value(col[i, t]):
if typ_name[i, t] == "Foundry":
v = (
"E"
if solver.Value(cvE[i, t])
else "B"
if solver.Value(cvB[i, t])
else "S"
)
return f"Collect vat {v}"
if typ_name[i, t] == "Metro":
picks = []
for nm, var in (("E", mE), ("B", mB), ("S", mS), ("C", mC)):
picks += [nm] * solver.Value(var[i, t])
return "Collect {" + ",".join(picks) + "}"
return "Collect (+Capital)"
if solver.Value(ow[i, t]):
if typ_name[i, t] == "Foundry":
v = (
"E"
if solver.Value(owcvE[i, t])
else "B"
if solver.Value(owcvB[i, t])
else "S"
)
return f"Overwork vat {v} (2x)"
if typ_name[i, t] == "Metro":
picks = []
for nm, var in (("E", owmE), ("B", owmB), ("S", owmS), ("C", owmC)):
picks += [nm] * solver.Value(var[i, t])
return "Overwork {" + ",".join(picks) + "} (2x)"
return "Overwork (+Capital 2x)"
if solver.Value(ua[i, t]):
return "Upgrade a (cost-reduction)"
if solver.Value(ub[i, t]):
return "Upgrade b (collect-bonus)"
if solver.Value(ud[i, t]):
return "Upgrade d (vat-increment)"
if solver.Value(rH[i, t]):
return "Renovate -> Hub"
if solver.Value(rF[i, t]):
return "Renovate -> Foundry"
if solver.Value(rM[i, t]):
return "Renovate -> Metro"
return "(inactive)"
print("\nPer-step pools (start of step):")
print(" step: E B S C")
for t in range(1, NUM_STEPS + 2):
label = f"after5" if t == NUM_STEPS + 1 else f"start {t}"
print(
f" {label:>8}: {solver.Value(E[t]):3} {solver.Value(B[t]):3} "
f"{solver.Value(S[t]):3} {solver.Value(C[t]):3}"
)
print("\nActions:")
for i in range(N):
a_step, a_type = cities[i]
print(f" City {i} (arrives step {a_step} as {a_type}):")
for t in range(a_step, NUM_STEPS + 1):
ups = "".join(
n
for n, h in (("a", hasA), ("b", hasB), ("d", hasD))
if solver.Value(h[i, t])
)
extra = (
f" vats(E{solver.Value(vE[i, t])},B{solver.Value(vB[i, t])},S{solver.Value(vS[i, t])})"
if typ_name[i, t] == "Foundry"
else ""
)
print(
f" step {t}: [{typ_name[i, t]:7}] {action_str(i, t):28}"
f" upg[{ups}]{extra}"
)
fe, fb, fs = solver.Value(finalE), solver.Value(finalB), solver.Value(finalS)
print(
f"\nFINAL E={fe} B={fb} S={fs} product = {fe * fb * fs} sum = {fe + fb + fs}"
)
if __name__ == "__main__":
solve()

View file

@ -4,16 +4,17 @@ from ortools.sat.python import cp_model
class IntermediateSolutionPrinter(cp_model.CpSolverSolutionCallback):
"""Callback that prints intermediate solutions."""
def __init__(self, variables):
def __init__(self, variables, *, scale=1.0):
cp_model.CpSolverSolutionCallback.__init__(self)
self._variables = variables
self._solution_count = 0
self.scale = scale
def on_solution_callback(self):
"""Called each time an improving solution is found."""
print("\n--- Solution ---")
for name, var in self._variables.items():
print(f"{name} = {self.Value(var)}")
print(f"{name} = {self.scale * self.Value(var)}")
@property
def solution_count(self):

View file

@ -5,5 +5,6 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"flask>=3.1.3",
"ortools>=9.15.6755",
]

1863
solve.py Normal file

File diff suppressed because it is too large Load diff

823
templates/solver.html Normal file
View file

@ -0,0 +1,823 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>City Resource Optimization Solver</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
}
h1 {
color: #333;
margin-bottom: 10px;
text-align: center;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
}
.form-section {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.form-section h2 {
color: #333;
font-size: 18px;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
input[type="number"],
input[type="text"],
select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
font-family: inherit;
}
input[type="number"]:focus,
input[type="text"]:focus,
select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.resource-inputs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.arrivals-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 15px;
}
.step-arrivals {
padding: 15px;
background: white;
border-radius: 5px;
border: 1px solid #e0e0e0;
}
.step-arrivals h3 {
color: #667eea;
font-size: 16px;
margin-bottom: 15px;
}
.cities-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.actions-grid,
.agents-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin-top: 15px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-group label {
margin: 0;
cursor: pointer;
color: #666;
}
.button-group {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 30px;
}
button {
padding: 12px 30px;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-solve {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
flex: 1;
max-width: 300px;
}
.btn-solve:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-solve:active {
transform: translateY(0);
}
.btn-reset {
background: #e0e0e0;
color: #333;
flex: 1;
max-width: 300px;
}
.btn-reset:hover {
background: #d0d0d0;
}
.btn-solve:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.output-section {
display: none;
margin-top: 30px;
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #ddd;
}
.output-section.show {
display: block;
}
.output-section h2 {
color: #333;
margin-bottom: 15px;
}
.status {
padding: 10px;
border-radius: 5px;
margin-bottom: 15px;
font-weight: 600;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
display: none;
text-align: center;
color: #667eea;
font-weight: 600;
}
.loading.show {
display: block;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.agent-steps-input {
font-size: 12px;
color: #999;
}
.constraint-rows {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
}
.constraint-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
gap: 10px;
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 5px;
align-items: center;
}
.constraint-row.governor {
grid-template-columns: 1fr 1fr 1fr 1fr auto;
}
.constraint-row select,
.constraint-row input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
.btn-remove {
background: #dc3545;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
min-width: 70px;
}
.btn-remove:hover {
background: #c82333;
}
.btn-add {
background: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
align-self: flex-start;
}
.btn-add:hover {
background: #218838;
}
pre {
background: white;
padding: 15px;
border-radius: 5px;
border: 1px solid #ddd;
overflow-x: auto;
}
details>summary {
cursor: pointer;
user-select: none;
}
details>summary:hover {
color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<h1>🏛️ City Resource Optimization</h1>
<p class="subtitle">Solve for optimal resource distribution across cities</p>
<form id="solverForm">
<!-- Initial Resources Section -->
<div class="form-section">
<h2>Initial Resources (Step 1 Start)</h2>
<div class="resource-inputs">
<div class="form-group">
<label for="initial_E">Electrum</label>
<input type="number" id="initial_E" name="initial_E" value="{{ (initial[0] / 10) | round(1) }}"
min="0" step="0.1">
</div>
<div class="form-group">
<label for="initial_B">Brass</label>
<input type="number" id="initial_B" name="initial_B" value="{{ initial[1] // 10 }}" min="0">
</div>
<div class="form-group">
<label for="initial_S">Steel</label>
<input type="number" id="initial_S" name="initial_S" value="{{ initial[2] // 10 }}" min="0">
</div>
<div class="form-group">
<label for="initial_C">Capital</label>
<input type="number" id="initial_C" name="initial_C" value="{{ initial[3] // 10 }}" min="0">
</div>
</div>
</div>
<!-- Arrivals Schedule Section -->
<div class="form-section">
<h2>Cities</h2>
<div class="arrivals-grid" id="citiesContainer"></div>
<button type="button" class="btn-add" onclick="addCity()">+ Add City</button>
</div>
<!-- Enabled Actions Section -->
<div class="form-section">
<h2>Available Actions</h2>
<div class="actions-grid">
{% for action_name in actions.keys() | sort %}
<div class="checkbox-group">
<input type="checkbox" id="action_{{ action_name }}" name="action_{{ action_name }}" {% if
actions[action_name] %}checked{% endif %}>
<label for="action_{{ action_name }}">{{ action_name }}</label>
</div>
{% endfor %}
</div>
</div>
<!-- Agent Availability Section -->
<div class="form-section">
<h2>Agent Availability</h2>
<p style="color: #666; font-size: 13px; margin-bottom: 15px;">
Specify steps where each agent is available (comma-separated, e.g., "1,2,3,4,5")
</p>
<div class="agents-grid">
{% for agent_name in agents.keys() | sort %}
<div class="form-group">
<label for="agent_{{ agent_name }}_steps">{{ agent_name }}</label>
<input type="text" id="agent_{{ agent_name }}_steps" name="agent_{{ agent_name }}_steps"
placeholder="e.g., 1,2,3" class="agent-steps-input" {% if agents[agent_name]
%}value="{{ agents[agent_name] | join(',') }}" {% endif %}>
</div>
{% endfor %}
</div>
</div>
<!-- Solver Settings Section -->
<div class="form-section">
<h2>Solver Settings</h2>
<div class="form-group">
<label for="time_limit">Time Limit (seconds)</label>
<input type="number" id="time_limit" name="time_limit" value="60" min="1">
</div>
<div class="checkbox-group">
<input type="checkbox" id="verbose" name="verbose">
<label for="verbose">Verbose Output</label>
</div>
</div>
<!-- Fixed Actions Section -->
<div class="form-section">
<h2>Fixed Actions (Optional)</h2>
<p style="color: #666; font-size: 13px; margin-bottom: 15px;">
Force specific actions for cities at specific steps
</p>
<div class="constraint-rows" id="actionConstraints"></div>
<button type="button" class="btn-add" onclick="addActionConstraint()">+ Add Action Constraint</button>
</div>
<!-- Fixed Governors Section -->
<div class="form-section">
<h2>Fixed Governors (Optional)</h2>
<p style="color: #666; font-size: 13px; margin-bottom: 15px;">
Force specific agents to govern cities at specific steps
</p>
<div class="constraint-rows" id="governorConstraints"></div>
<button type="button" class="btn-add" onclick="addGovernorConstraint()">+ Add Governor
Constraint</button>
</div>
<!-- Resource Constraints Section -->
<div class="form-section">
<h2>Resource Constraints (Optional)</h2>
<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>
</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]&gt;=10</code> checks if electrum is
greater or equal than 1 not 10 on day 2</p>
<div class="constraint-rows" id="resourceConstraints"></div>
<button type="button" class="btn-add" onclick="addResourceConstraint()">+ Add Resource
Constraint</button>
</div>
<!-- Buttons -->
<div class="button-group">
<button type="submit" class="btn-solve">Solve</button>
<button type="reset" class="btn-reset">Reset</button>
</div>
</form>
<!-- Loading Indicator -->
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Solving... This may take a moment.</p>
</div>
<!-- Output Section -->
<div class="output-section" id="outputSection">
<h2>Results</h2>
<div id="outputContainer"></div>
</div>
</div>
<script>
let cityCount = 0;
let actionConstraintCount = 0;
let governorConstraintCount = 0;
let resourceConstraintCount = 0;
const AVAILABLE_ACTIONS = {{actions | tojson }};
const AVAILABLE_AGENTS = {{agents | tojson }};
const NUM_STEPS = {{num_steps}};
function updateDepartureOptions(cityId) {
const arrivalSelect = document.getElementById(`city_${cityId}_arrival_step`);
const departureSelect = document.getElementById(`city_${cityId}_departure_step`);
if (!arrivalSelect || !departureSelect) return;
const arrivalStep = parseInt(arrivalSelect.value);
const currentDeparture = departureSelect.value;
// Clear departure options
departureSelect.innerHTML = '';
// Add Game End option
const gameEndOption = document.createElement('option');
gameEndOption.value = NUM_STEPS + 1;
gameEndOption.textContent = 'Game End';
departureSelect.appendChild(gameEndOption);
// Add step options only for steps after arrival
for (let step = arrivalStep + 1; step <= NUM_STEPS; step++) {
const option = document.createElement('option');
option.value = step;
option.textContent = `Step ${step}`;
departureSelect.appendChild(option);
}
// Set departure to Game End if current value is no longer valid
if (currentDeparture <= arrivalStep) {
departureSelect.value = NUM_STEPS + 1;
} else if (departureSelect.querySelector(`option[value="${currentDeparture}"]`)) {
departureSelect.value = currentDeparture;
}
}
function updateVatInputs(cityId) {
const typeSelect = document.getElementById(`city_${cityId}_type`);
const vatsContainer = document.getElementById(`city_${cityId}_vats`);
if (!typeSelect || !vatsContainer) return;
if (typeSelect.value === 'F') {
vatsContainer.style.display = 'block';
} else {
vatsContainer.style.display = 'none';
}
}
function addCity() {
const container = document.getElementById('citiesContainer');
const id = cityCount++;
const cityDiv = document.createElement('div');
cityDiv.className = 'step-arrivals';
cityDiv.id = `city-${id}`;
cityDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">City ${id}</h3>
<button type="button" class="btn-remove" style="margin: 0;" onclick="document.getElementById('city-${id}').remove()">Remove</button>
</div>
<div class="form-group">
<label for="city_${id}_type">Type</label>
<select id="city_${id}_type" name="city_${id}_type" onchange="updateVatInputs(${id})">
<option value="H">Hub</option>
<option value="F">Foundry</option>
<option value="M">Metropolis</option>
<option value="N">Monument</option>
</select>
</div>
<div class="form-group">
<label for="city_${id}_arrival_step">Arrival Step</label>
<select id="city_${id}_arrival_step" name="city_${id}_arrival_step" onchange="updateDepartureOptions(${id})">
${Array.from({length: NUM_STEPS}, (_, i) => `<option value="${i + 1}">Step ${i + 1}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="city_${id}_departure_step">Departure Step</label>
<select id="city_${id}_departure_step" name="city_${id}_departure_step">
<option value="${NUM_STEPS + 1}">Game End</option>
${Array.from({length: NUM_STEPS}, (_, i) => `<option value="${i + 1}">Step ${i + 1}</option>`).join('')}
</select>
</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">
</div>
<div id="city_${id}_vats" style="display: none;">
<p style="color: #666; font-size: 12px; margin-bottom: 10px; margin-top: 10px;">Foundry Vat Values (defaults to 1):</p>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">
<div class="form-group">
<label for="city_${id}_vat_E">Electrum</label>
<input type="number" id="city_${id}_vat_E" name="city_${id}_vat_E" value="1" min="0">
</div>
<div class="form-group">
<label for="city_${id}_vat_B">Brass</label>
<input type="number" id="city_${id}_vat_B" name="city_${id}_vat_B" value="1" min="0">
</div>
<div class="form-group">
<label for="city_${id}_vat_S">Steel</label>
<input type="number" id="city_${id}_vat_S" name="city_${id}_vat_S" value="1" min="0">
</div>
</div>
</div>
`;
container.appendChild(cityDiv);
// Initialize departure options based on default arrival step (1)
updateDepartureOptions(id);
}
function addActionConstraint() {
const container = document.getElementById('actionConstraints');
const id = actionConstraintCount++;
// Get existing city indices
const existingCities = Array.from(document.querySelectorAll('[id^="city-"]')).map(el =>
parseInt(el.id.split('-')[1])
);
const maxCity = existingCities.length > 0 ? Math.max(...existingCities) + 1 : 1;
const cityOptions = Array.from({length: maxCity}, (_, i) =>
`<option value="${i}">${i}</option>`
).join('');
const row = document.createElement('div');
row.className = 'constraint-row';
row.id = `action-constraint-${id}`;
row.innerHTML = `
<select name="action_city_${id}" class="action-city">
${cityOptions}
</select>
<select name="action_step_${id}">
${Array.from({length: NUM_STEPS}, (_, i) => `<option value="${i + 1}">Step ${i + 1}</option>`).join('')}
</select>
<select name="action_action_${id}">
${Object.keys(AVAILABLE_ACTIONS).map(a => `<option value="${a}">${a}</option>`).join('')}
</select>
<button type="button" class="btn-remove" onclick="document.getElementById('action-constraint-${id}').remove()">Remove</button>
`;
container.appendChild(row);
}
function addGovernorConstraint() {
const container = document.getElementById('governorConstraints');
const id = governorConstraintCount++;
// Get existing city indices
const existingCities = Array.from(document.querySelectorAll('[id^="city-"]')).map(el =>
parseInt(el.id.split('-')[1])
);
const maxCity = existingCities.length > 0 ? Math.max(...existingCities) + 1 : 1;
const cityOptions = Array.from({length: maxCity}, (_, i) =>
`<option value="${i}">${i}</option>`
).join('');
const row = document.createElement('div');
row.className = 'constraint-row governor';
row.id = `governor-constraint-${id}`;
row.innerHTML = `
<select name="gov_city_${id}">
${cityOptions}
</select>
<select name="gov_step_${id}">
${Array.from({length: NUM_STEPS}, (_, i) => `<option value="${i + 1}">Step ${i + 1}</option>`).join('')}
</select>
<select name="gov_agent_${id}">
${Object.keys(AVAILABLE_AGENTS).map(a => `<option value="${a}">${a}</option>`).join('')}
</select>
<div class="checkbox-group" style="margin: 0; gap: 4px;">
<input type="checkbox" name="gov_enabled_${id}" id="gov_enabled_${id}" checked>
<label for="gov_enabled_${id}" style="margin: 0;">Enabled</label>
</div>
<button type="button" class="btn-remove" onclick="document.getElementById('governor-constraint-${id}').remove()">Remove</button>
`;
container.appendChild(row);
}
function addResourceConstraint() {
const container = document.getElementById('resourceConstraints');
const id = resourceConstraintCount++;
const row = document.createElement('div');
row.className = 'constraint-row';
row.id = `resource-constraint-${id}`;
row.style.gridTemplateColumns = '1fr auto';
row.innerHTML = `
<input type="text" name="resource_expr_${id}" placeholder="e.g., E[3] >= 50 or B[2] + S[2] >= 100" style="flex: 1;">
<button type="button" class="btn-remove" onclick="document.getElementById('resource-constraint-${id}').remove()">Remove</button>
`;
container.appendChild(row);
}
document.getElementById('solverForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData);
// Convert verbose checkbox to boolean
data['verbose'] = document.getElementById('verbose').checked;
// Convert checked checkboxes to true/false for all action enable/disable inputs
const enableCheckboxes = form.querySelectorAll('input[name^="action_"][type="checkbox"]');
enableCheckboxes.forEach(input => {
data[input.name] = input.checked;
});
// Collect fixed action constraints
const fixedActions = {};
form.querySelectorAll('[id^="action-constraint-"]').forEach(row => {
const city = parseInt(row.querySelector('[name^="action_city_"]').value);
const step = parseInt(row.querySelector('[name^="action_step_"]').value);
const action = row.querySelector('[name^="action_action_"]').value;
fixedActions[`${city},${step}`] = action;
});
// Collect fixed governor constraints
const fixedGovernors = {};
form.querySelectorAll('[id^="governor-constraint-"]').forEach(row => {
const city = parseInt(row.querySelector('[name^="gov_city_"]').value);
const step = parseInt(row.querySelector('[name^="gov_step_"]').value);
const agent = row.querySelector('[name^="gov_agent_"]').value;
const enabled = row.querySelector('[name^="gov_enabled_"]').checked;
fixedGovernors[`${city},${step},${agent}`] = enabled;
});
// Collect resource constraints
const resourceConstraints = [];
form.querySelectorAll('[id^="resource-constraint-"]').forEach(row => {
const expr = row.querySelector('[name^="resource_expr_"]').value.trim();
if (expr) {
resourceConstraints.push(expr);
}
});
// Add constraints to data if any exist
if (Object.keys(fixedActions).length > 0) {
data.fixed_actions = fixedActions;
}
if (Object.keys(fixedGovernors).length > 0) {
data.fixed_governors = fixedGovernors;
}
if (resourceConstraints.length > 0) {
data.resource_constraints = resourceConstraints;
}
document.getElementById('loading').classList.add('show');
document.getElementById('outputSection').classList.remove('show');
try {
const response = await fetch('/solve', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
document.getElementById('loading').classList.remove('show');
document.getElementById('outputSection').classList.add('show');
// Generate summary of configuration
const numCities = Array.from(document.querySelectorAll('[id^="city-"]')).filter(
el => document.getElementById(`city_${el.id.split('-')[1]}_type`).value !== 'none'
).length;
const timestamp = new Date().toLocaleTimeString();
const summaryText = `${timestamp} • ${numCities} city(cities) • ${result.status}`;
if (result.success) {
// Create new result section
const resultDiv = document.createElement('div');
resultDiv.style.marginBottom = '20px';
resultDiv.innerHTML = `
<div class="status success" style="margin-bottom: 10px;">✓ ${summaryText}</div>
<details open>
<summary>Solver Output</summary>
<pre>${result.output}</pre>
</details>
`;
// Prepend to output container (newest first)
const container = document.getElementById('outputContainer');
container.insertBefore(resultDiv, container.firstChild);
} else {
// Create new error section
const resultDiv = document.createElement('div');
resultDiv.style.marginBottom = '20px';
resultDiv.innerHTML = `
<div class="status error" style="margin-bottom: 10px;">✗ ${summaryText}</div>
<details open>
<summary>Error Details</summary>
<pre>${result.error}</pre>
</details>
`;
const container = document.getElementById('outputContainer');
container.insertBefore(resultDiv, container.firstChild);
}
} catch (error) {
document.getElementById('loading').classList.remove('show');
document.getElementById('outputSection').classList.add('show');
const timestamp = new Date().toLocaleTimeString();
const resultDiv = document.createElement('div');
resultDiv.style.marginBottom = '20px';
resultDiv.innerHTML = `
<div class="status error" style="margin-bottom: 10px;">✗ ${timestamp} • Request failed</div>
<details open>
<summary>Error Details</summary>
<pre>${error.message}</pre>
</details>
`;
const container = document.getElementById('outputContainer');
container.insertBefore(resultDiv, container.firstChild);
}
});
// Initialize with one city on page load
document.addEventListener('DOMContentLoaded', () => {
addCity();
});
</script>
</body>
</html>

138
uv.lock
View file

@ -19,6 +19,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "immutabledict"
version = "4.3.1"
@ -28,6 +75,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/ce/f9018bf69ae91b273b6391a095e7c93fa5e1617f25b6ba81ad4b20c9df10/immutabledict-4.3.1-py3-none-any.whl", hash = "sha256:c9facdc0ff30fdb8e35bd16532026cac472a549e182c94fa201b51b25e4bf7bf", size = 5000, upload-time = "2026-02-15T10:32:33.672Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
@ -192,11 +312,15 @@ name = "solve"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "ortools" },
]
[package.metadata]
requires-dist = [{ name = "ortools", specifier = ">=9.15.6755" }]
requires-dist = [
{ name = "flask", specifier = ">=3.1.3" },
{ name = "ortools", specifier = ">=9.15.6755" },
]
[[package]]
name = "typing-extensions"
@ -215,3 +339,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35c
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]

177
web_solve.py Normal file
View file

@ -0,0 +1,177 @@
"""Web interface for City Resource Optimization solver."""
from flask import Flask, render_template, request, jsonify
from ortools.sat.python import cp_model
import json
import solve
import io
import sys
from contextlib import redirect_stdout
from constraint_validator import make_constraint_lambda
app = Flask(__name__)
@app.route("/")
def index():
"""Serve the solver form."""
return render_template(
"solver.html",
initial=solve.INITIAL,
actions=solve.ENABLED_ACTIONS,
agents=solve.AGENT_AVAILABILITY,
num_steps=solve.NUM_STEPS,
)
@app.route("/solve", methods=["POST"])
def solve_handler():
"""Handle solver requests from the form."""
try:
data = request.get_json()
# Parse initial resources (divide by 10 since UI shows unscaled values)
initial = tuple(
int(float(data.get(f"initial_{r}", 3)) * 10) for r in ["E", "B", "S", "C"]
)
# Parse per-city arrivals and departures (dynamic number of cities)
arrivals = {}
for step in range(1, solve.NUM_STEPS + 1):
arrivals[step] = []
# Find all cities submitted (city_0_type, city_1_type, etc.)
city_indices = set()
for key in data.keys():
if key.startswith("city_") and key.endswith("_type"):
city_idx = int(key.split("_")[1])
city_indices.add(city_idx)
for city_idx in sorted(city_indices):
city_type = data.get(f"city_{city_idx}_type")
arrival_step_str = data.get(f"city_{city_idx}_arrival_step")
departure_step_str = data.get(f"city_{city_idx}_departure_step")
adjacent_str = data.get(f"city_{city_idx}_adjacent", "")
if city_type and city_type != "none" and arrival_step_str:
arrival_step = int(arrival_step_str)
departure_step = (
int(departure_step_str)
if departure_step_str
else solve.NUM_STEPS + 1
)
# Parse adjacent cities (comma-separated, whitespace ignored)
adjacent_to = []
if adjacent_str:
adjacent_to = [
int(idx.strip())
for idx in adjacent_str.split(",")
if idx.strip()
]
city_data = {
"type": city_type,
"adjacent_to": adjacent_to,
"departure_step": departure_step,
}
# Parse vat values for foundries (if specified, use them; otherwise let normalize_city default to 1)
if city_type == "F":
vat_e = data.get(f"city_{city_idx}_vat_E")
vat_b = data.get(f"city_{city_idx}_vat_B")
vat_s = data.get(f"city_{city_idx}_vat_S")
vats = {}
if vat_e:
vats["E"] = int(vat_e)
if vat_b:
vats["B"] = int(vat_b)
if vat_s:
vats["S"] = int(vat_s)
if vats:
city_data["vats"] = vats
arrivals[arrival_step].append(city_data)
# Parse enabled actions
enabled_actions = {}
for action_name in solve.ENABLED_ACTIONS.keys():
enabled_actions[action_name] = data.get(f"action_{action_name}", False)
# Parse agent availability
agent_availability = {}
for agent_name in solve.AGENT_AVAILABILITY.keys():
steps_str = data.get(f"agent_{agent_name}_steps", "")
if steps_str:
agent_availability[agent_name] = [
int(s) for s in steps_str.split(",") if s
]
else:
agent_availability[agent_name] = []
# Parse time limit
time_limit = float(data.get("time_limit", 60.0))
# Parse verbose flag
verbose = data.get("verbose", True)
# Parse fixed constraints
fixed_choices = None
fixed_actions_data = data.get("fixed_actions", {})
fixed_governors_data = data.get("fixed_governors", {})
if fixed_actions_data or fixed_governors_data:
fixed_choices = {}
if fixed_actions_data:
fixed_choices["actions"] = {}
for key, action in fixed_actions_data.items():
city, step = map(int, key.split(","))
fixed_choices["actions"][(city, step)] = action
if fixed_governors_data:
fixed_choices["governors"] = {}
for key, enabled in fixed_governors_data.items():
city, step, agent = key.rsplit(",", 2)
city, step = int(city), int(step)
fixed_choices["governors"][(city, step, agent)] = enabled
# Parse resource constraints
resource_constraints = None
resource_constraints_data = data.get("resource_constraints", [])
if resource_constraints_data:
resource_constraints = []
for expr in resource_constraints_data:
try:
constraint_lambda = make_constraint_lambda(expr)
resource_constraints.append(constraint_lambda)
except Exception as e:
raise ValueError(f"Invalid resource constraint '{expr}': {e}")
# Capture solver output
output_buffer = io.StringIO()
with redirect_stdout(output_buffer):
solver, status = solve.solve(
initial=initial,
arrivals=arrivals,
max_res=solve.MAX_RES,
max_vat=solve.MAX_VAT,
# min to avoid bricking stuff
time_limit=min(time_limit, 60.0),
num_workers=1,
verbose=verbose,
fixed_choices=fixed_choices,
resource_constraints=resource_constraints,
)
output = output_buffer.getvalue()
status_name = solver.StatusName(status) if status else "UNKNOWN"
return jsonify({"success": True, "status": status_name, "output": output})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 400
if __name__ == "__main__":
app.run(debug=False, host="0.0.0.0", port=5000)