Compare commits

...

No commits in common. "new-main" and "main" have entirely different histories.

18 changed files with 3904 additions and 4015 deletions

6
.gitignore vendored
View file

@ -1,4 +1,4 @@
.serena
__pycache__
.venv
*.pdf
*.db
output.txt
.worktrees

2
.serena/.gitignore vendored
View file

@ -1,2 +0,0 @@
/cache
/project.local.yml

View file

@ -1,133 +0,0 @@
# the name by which the project can be referenced within Serena
project_name: "dws-solve"
# list of languages for which language servers are started; choose from:
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# svelte swift systemverilog terraform toml
# typescript typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- python
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []

View file

@ -5,19 +5,19 @@ WORKDIR /app
# Install uv
RUN pip install --no-cache-dir uv
# Install dependencies first (cached unless the lockfile changes)
# Copy project files
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project
# Install dependencies with uv
RUN uv sync --no-editable
# Copy source code
COPY solve.py main.py index.html ./
COPY solve.py web_solve.py ./
COPY templates/ templates/
# Bind to all interfaces so the server is reachable outside the container
ENV HOST=0.0.0.0 \
PORT=8000 \
PYTHONUNBUFFERED=1
# Set environment variables
ENV FLASK_APP=web_solve.py
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# Run the stdlib HTTP server
CMD ["uv", "run", "--frozen", "python", "main.py"]
# Run Flask app
CMD ["uv", "run", "python", "web_solve.py"]

157
README.md
View file

@ -1,154 +1,25 @@
# dws-solve
# DWS city resource solver
A planner optimizer for the **Days Without Strife** Faction *Planner* role,
built on Google OR-Tools (CP-SAT). It plans Industry Actions across the game's
Turns to maximize a weighted score over the end-game resources.
This is a Python program which solves for maximal resources in days without strife with Google OR tool's CP-SAT solver.
See `solve.py` for details.
`web_solve.py` provides a web UI to churn through various scenarios more easily.
## What it optimizes
`agents.txt` is all the auction and brotherhood agents I could copy and paste from the Nomad's guide in early June 2026 it and the way agents work in `solve.py` may be out of date.
Given:
- the Cities a Faction controls (type, renown, foundry vats, installed upgrades),
- the Agents available to appoint as Governors (notably the **Planner** with
*Overwork*),
- per-turn **availability** for cities and agents,
- optional **hard constraints** (force a city's action on a turn; force an agent
to govern a particular city on a turn),
- starting resources,
`printer.py` provides some code for verbose logging in `solve.py`.
it finds the sequence of per-city Industry Actions (Collect / Renovate /
Upgrade / Launch Airship / Idle) that maximizes the objective.
## WARNING: Vibe Coded
Objective is one of two forms over the resources
*Renown, Luxuries, Capital, Steel, Brass, Electrum, Trade Goods, Express tickets*:
If you see something nonsensical that's why.
- **linear**: `sum_n scalar_n * amount_n`
- **log**: `sum_n scalar_n * log_mapping[n][amount_n]` where `log_mapping` is a
caller-supplied lookup table (list indexed by integer resource amount).
For me this work is disposable so issues with quality resulting from LLM usage is fine.
"Renown" is scored as the **total final Renown of controlled assets** (each
City capped to 1..9), plus `extra_renown` for assets not modeled as Cities.
I suspect regardless of LLM usage `solve.py` would be a rats nest either way due to the fact that encoding constraints is both tedious and obtuse although the LLM following in the footsteps of mathemeticians with overly terse variable names doesn't help.
## Usage
## Running
### As a CLI (JSON in / JSON out)
There's a dockerfile and docker compose if you like using those but otherwise you'll need Google OR tools for just the optimizer in `solve.py` if you're running that directly, in a repl or a notebook.
```bash
uv run python solve.py input.json --time 30 -o plan.json
# or read stdin / write stdout
uv run python solve.py < input.json
```
You'll need Flask if you wanna run the web UI.
### As a module
```python
from solve import Problem, City, Agent, Objective, CityType, solve
problem = Problem(
turns=5,
start={"capital": 3, "luxuries": 3, "steel": 3, "brass": 3, "electrum": 3},
cities=[
City("Aridias", CityType.HUB, renown=2),
City("Bearhearth", CityType.FOUNDRY, renown=2,
vat_steel=3, vat_brass=2, vat_electrum=1),
City("Kingsland", CityType.METROPOLIS, renown=4, can_renovate=False),
],
agents=[Agent("Planner", overwork=True)],
objective=Objective(mode="linear",
scalars={"renown": 5, "electrum": 2, "express": 3}),
)
solution = solve(problem, max_time_seconds=30)
print(solution.objective_value, solution.final_renown_total)
for step in solution.plan:
print(step)
```
## Input JSON schema
```jsonc
{
"turns": 5,
"start": {"capital": 3, "luxuries": 3, "steel": 3, "brass": 3, "electrum": 3},
"extra_renown": 0, // renown of non-City assets
"tradeable_into": ["capital","luxuries","steel","brass","electrum"],
"max_resource": 300, "max_vat": 12, // accumulator bounds (optional)
"cities": [
{
"name": "Aridias", "type": "hub", "renown": 2,
"vat_steel": 0, "vat_brass": 0, "vat_electrum": 0, // foundry only
"upgrades": [], // already-installed upgrade keys
"available_turns": null, // null = all turns, or e.g. [0,2,4]
"can_renovate": true, // metropolis must be false
"forced_action": {"1": "renovate"} // turn -> action (hard constraint)
}
],
"agents": [
{
"name": "Planner", "overwork": true,
"free_upgrade": false, // e.g. Brotherhood Builder
"bonus_trade_goods": 0, // e.g. Baron
"available_turns": null,
"forced_city": {"0": "Mon1"} // turn -> city (hard constraint)
}
],
"objective": {
"mode": "linear", // or "log"
"scalars": {"renown": 5, "electrum": 2},
"log_mapping": { // required for "log" mode
"renown": [0,0,1,2,3,4,5,6,7,8,9]
}
}
}
```
Action keys: `collect`, `renovate`, `upgrade`, `launch`, `idle`.
City types: `hub`, `foundry`, `monument`, `metropolis`.
Upgrade keys: `infrastructure`, `harvester`, `fine_dining`, `overflow_vats`,
`transit_authority`, `propaganda`, `fortification`.
## Modeled mechanics
- **Collection** per city type (Hub capital/luxuries choice, Foundry vat pick,
Monument/Metropolis renown + trade goods), with the **1 Capital** cost.
- **Foundry vats**: collecting a vat yields its level, empties it, and adds +1
to the other two (full stateful per-turn model; *Overflow Vats* adds +1 more).
- **Overwork** (Planner Governor): doubles a city's collection that turn, waives
the Capital cost, and **locks** that city out of collecting the next turn.
- **Upgrades** with Steel costs (Infrastructure 0 / Harvester 2 / type-specific
2 / Fortification 4), Infrastructure's 1 future-cost discount, and the +1/+2
Renown each grants. *Harvester*, *Fine Dining*, *Transit Authority* yield
effects are applied; *Propaganda*/*Fortification* military effects are not.
- **Renovation** changes a city's type for subsequent turns.
- **Airship launch** (7 Steel; adds +3 to the asset Renown total).
- Resource balances are constrained **non-negative every turn**, so the plan is
always affordable in sequence.
- **Trade Goods exchange**: each Turn, Trade Goods may be converted 1-for-1 into
any resource in `tradeable_into`; the converted resource is available that same
Turn (so it can fund Upgrades/Airships). Reported under `trade_conversions`.
- **Wildcard governor Agents** via `Agent.planner()` / `Agent.baron()` (assumes
3 Bastions → +3 Trade Goods on Collect) / `Agent.builder()` (free type Upgrade),
or the generic `overwork` / `bonus_trade_goods` / `free_upgrade` flags.
## Simplifications
- Buying Trade Goods with Electrum (2.5 each) is not modeled.
- Combat, diplomacy, espionage, bidding, and travel are out of scope — this
optimizes the Planner's resource/upgrade decisions only.
- Resource amounts are integers; fractional starting Electrum is rounded.
## Output
```jsonc
{
"status": "OPTIMAL", // or FEASIBLE / INFEASIBLE
"objective_value": 231.0,
"final_resources": {"capital": 2.0, ...},
"final_renown_total": 35,
"plan": [
{"turn": 0, "city": "Aridias", "action": "collect",
"detail": "hub: +2 luxuries", "governor": "", "overwork": false}
]
}
```
See `example.json` for a complete runnable input.
If you're lazy and not choosing docker you can use uv to run this.

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__": {}})

View file

@ -1,16 +1,14 @@
version: '3.8'
services:
web:
build: .
ports:
- "5555:8000"
- "5555:5000"
environment:
- HOST=0.0.0.0
- PORT=8000
# Stored solves live on the mounted volume so they survive restarts.
- DB_PATH=/data/solves.db
- FLASK_ENV=development
- FLASK_DEBUG=1
volumes:
- solves:/data
- .:/app
command: uv run python web_solve.py
restart: unless-stopped
volumes:
solves:

View file

@ -0,0 +1,311 @@
# Configurable Maximize Criteria Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the maximize criteria a parameter of `solve()`: a factors dict (E/B/S/C) plus a "product"/"sum" mode, defaulting to the current hardcoded `E²·B·S²` product.
**Architecture:** All changes live in `solve.py`. Module-level defaults (`OBJECTIVE_FACTORS`, `OBJECTIVE_MODE`) follow the existing `INITIAL`/`FIXED_CHOICES` pattern; `solve()` resolves `None` args to them (same convention as `resource_constraints`). The hand-chained `AddMultiplicationEquality` objective block is replaced by a generic fold (product mode) or a single linear `Maximize` (sum mode). Phase-1 ceilings are computed only for resources with a nonzero factor. `_report` prints the expression that was actually maximized.
**Tech Stack:** Python 3.13, Google OR-Tools CP-SAT (`cp_model`).
**Spec:** `docs/superpowers/specs/2026-06-11-configurable-objective-design.md`
**Testing:** Per user instruction, no automated tests — the user validates manually.
---
### Task 1: Module-level defaults and validation helper
**Files:**
- Modify: `solve.py` (PARAMETERS section, after `RESOURCE_CONSTRAINTS` at ~line 68)
- [ ] **Step 1: Add the defaults and `_validate_objective`**
Insert after the `RESOURCE_CONSTRAINTS = []` line:
```python
# Maximize criteria for solve(). Factor = exponent in "product" mode,
# weight in "sum" mode. Keys: E, B, S, C; missing keys = 0 (resource
# excluded from the objective — it is NOT forced to zero). Negative
# factors are allowed only in "sum" mode (a negative exponent would mean
# division, which integer CP-SAT cannot express).
OBJECTIVE_FACTORS = {"E": 2, "B": 1, "S": 2}
OBJECTIVE_MODE = "product" # "product" or "sum"
def _validate_objective(factors, mode):
if mode not in ("product", "sum"):
raise ValueError(f"objective_mode must be 'product' or 'sum', got {mode!r}")
unknown = set(factors) - {"E", "B", "S", "C"}
if unknown:
raise ValueError(f"unknown objective_factors keys: {sorted(unknown)}")
for k, v in factors.items():
if not isinstance(v, int) or isinstance(v, bool):
raise ValueError(f"objective factor {k}={v!r} must be an int")
if mode == "product" and v < 0:
raise ValueError(
f"objective factor {k}={v} is negative; negative exponents "
"are not expressible in product mode"
)
if not any(factors.values()):
raise ValueError("objective_factors needs at least one nonzero factor")
```
(The `isinstance(v, bool)` check exists because `bool` is a subclass of `int` in Python; `True` as a factor is almost certainly a caller bug.)
- [ ] **Step 2: Commit**
```bash
git add solve.py
git commit -m "feat: add objective factors/mode defaults and validation"
```
---
### Task 2: Accept and resolve the new `solve()` arguments
**Files:**
- Modify: `solve.py``solve()` signature (~line 981) and top of the function body
- [ ] **Step 1: Extend the signature**
Add two keyword args to `solve()` (after `resource_constraints=None`):
```python
def solve(
*,
initial=INITIAL,
arrivals=ARRIVALS,
max_res=MAX_RES,
max_vat=MAX_VAT,
time_limit=60.0,
num_workers=8,
verbose=True,
fixed_choices=FIXED_CHOICES,
resource_constraints=None,
objective_factors=None,
objective_mode=None,
):
```
- [ ] **Step 2: Resolve defaults and validate, first thing in the body**
Insert at the very top of the function body, before the `# ---- build the city list` block, so bad input fails before any model construction:
```python
if objective_factors is None:
objective_factors = OBJECTIVE_FACTORS
if objective_mode is None:
objective_mode = OBJECTIVE_MODE
_validate_objective(objective_factors, objective_mode)
# Normalized copy: every key present, missing keys = 0.
obj_factors = {k: objective_factors.get(k, 0) for k in "EBSC"}
```
- [ ] **Step 3: Commit**
```bash
git add solve.py
git commit -m "feat: solve() accepts objective_factors and objective_mode"
```
---
### Task 3: Compute Phase-1 ceilings only for resources in the objective
**Files:**
- Modify: `solve.py` — Phase-1 block (~lines 1536-1554, `def _ceiling` through the three `m.Add(final* <= cap*)` lines) and the verbose ceilings print (~line 1596)
- [ ] **Step 1: Replace the fixed capE/capB/capS computation with a caps dict**
The existing line `finalE, finalB, finalS = E[NUM_STEPS + 1], ...` stays. Just below it, replace this block:
```python
capE = _ceiling(finalE)
capB = _ceiling(finalB)
capS = _ceiling(finalS)
m.Add(finalE <= capE)
m.Add(finalB <= capB)
m.Add(finalS <= capS)
```
with (keeping `def _ceiling(var):` as is, above it):
```python
finals = {"E": finalE, "B": finalB, "S": finalS, "C": C[NUM_STEPS + 1]}
# Ceilings only for resources that appear in the objective: each
# ceiling solve costs up to 20s, and only objective resources need
# bounds (product mode) / benefit from the redundant cap constraint.
caps = {}
for k, var in finals.items():
if obj_factors[k] != 0:
caps[k] = _ceiling(var)
m.Add(var <= caps[k])
```
- [ ] **Step 2: Update the verbose ceilings print**
Replace:
```python
print(f"(resource ceilings used: E<={capE} B<={capB} S<={capS})")
```
with:
```python
caps_str = " ".join(f"{k}<={v}" for k, v in caps.items())
print(f"(resource ceilings used: {caps_str})")
```
- [ ] **Step 3: Commit**
```bash
git add solve.py
git commit -m "feat: compute resource ceilings only for objective resources"
```
---
### Task 4: Generic objective builder (product fold / linear sum)
**Files:**
- Modify: `solve.py` — the "OBJECTIVE IS SET HERE" block (~lines 1556-1578)
- [ ] **Step 1: Replace the hardcoded objective block**
Delete the whole block from `def Eprod(v):` through `m.Maximize(obj)` (including `Eprod`/`Bprod`/`Sprod` and the `prodEE`/`prodSS`/`prodBB`/`prodEB`/`obj` variables) and replace with:
```python
# ======================================================================
# OBJECTIVE IS SET HERE
# ======================================================================
if objective_mode == "sum":
# Linear: CP-SAT takes weighted sums (negative weights included)
# directly, no auxiliary variables needed.
m.Maximize(sum(f * finals[k] for k, f in obj_factors.items() if f))
else:
# Product: maximize prod(finals[k] ** obj_factors[k]). Expand the
# exponents into a flat factor list and fold pairwise, carrying a
# running upper bound from the Phase-1 caps.
factor_keys = [k for k, f in obj_factors.items() for _ in range(f)]
obj = finals[factor_keys[0]]
bound = caps[factor_keys[0]]
for k in factor_keys[1:]:
bound *= caps[k]
nxt = m.NewIntVar(0, bound, "")
m.AddMultiplicationEquality(nxt, [obj, finals[k]])
obj = nxt
m.Maximize(obj)
```
Notes for the implementer:
- `factor_keys` for the default `{"E": 2, "B": 1, "S": 2}` is `["E", "E", "B", "S", "S"]`, reproducing the old `E²·B·S²` objective.
- A single-factor product (e.g. `{"E": 1}`) skips the loop entirely and maximizes `finalE` directly — that's correct.
- Every key in `factor_keys` has a nonzero factor, so `caps[k]` always exists (Task 3 computed caps for exactly those keys).
- [ ] **Step 2: Commit**
```bash
git add solve.py
git commit -m "feat: build objective from factors dict in product or sum mode"
```
---
### Task 5: Report the actually-maximized objective
**Files:**
- Modify: `solve.py``_report()` signature (~line 1651) and its FINAL print (~lines 1854-1858); the `_report(...)` call inside `solve()` (~line 1597)
- [ ] **Step 1: Add objective params to `_report`'s signature**
Append two keyword params after `baron_deposits=None`:
```python
baron_deposits=None,
obj_factors=None,
obj_mode=None,
):
```
- [ ] **Step 2: Replace the FINAL print**
Replace:
```python
fe, fb, fs = solver.Value(finalE), solver.Value(finalB), solver.Value(finalS)
print(
f"\nFINAL E={fe / 10:.1f} B={fb / 10:.1f} S={fs / 10:.1f} "
f"product(scaled) = {fe * fb * fs / 1000} sum = {(fe + fb + fs) / 10:.1f}"
)
```
with:
```python
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])}
if obj_factors is None:
# Legacy fallback: old hardcoded E*B*S display.
obj_str = f"product(scaled) = {fe * fb * fs / 1000}"
elif obj_mode == "sum":
terms = [
f"{f}{k}" if abs(f) != 1 else (k if f > 0 else f"-{k}")
for k, f in obj_factors.items()
if f
]
expr = " + ".join(terms).replace("+ -", "- ")
raw = sum(f * vals[k] for k, f in obj_factors.items())
obj_str = f"objective {expr} = {raw / 10:.1f}"
else:
expr = "*".join(
k if f == 1 else f"{k}^{f}" for k, f in obj_factors.items() if f
)
raw, n = 1, 0
for k, f in obj_factors.items():
raw *= vals[k] ** f
n += f
# Resource values are x10-scaled, so descale by 10^(sum of exponents).
obj_str = f"objective {expr} = {raw / 10**n}"
print(
f"\nFINAL E={fe / 10:.1f} B={fb / 10:.1f} S={fs / 10:.1f} "
f"{obj_str} sum = {(fe + fb + fs) / 10:.1f}"
)
```
Display examples: product `{"E": 2, "B": 1, "S": 2}``objective E^2*B*S^2 = ...` descaled by `10^5`; sum `{"E": 2, "B": 1, "S": -1}``objective 2E + B - S = ...` descaled by 10.
- [ ] **Step 3: Pass the objective through from `solve()`**
At the `_report(...)` call inside `solve()`, append two keyword arguments after `baron_deposits,`:
```python
baron_deposits,
obj_factors=obj_factors,
obj_mode=objective_mode,
)
```
- [ ] **Step 4: Commit**
```bash
git add solve.py
git commit -m "feat: report prints the actually maximized objective"
```
---
### Task 6: Smoke check (user validation)
Per user instruction there are no automated tests. A quick way to confirm the default path is byte-for-byte behavior-compatible:
- [ ] **Step 1: Syntax check**
Run: `uv run python -c "import solve"`
Expected: no output, exit 0.
- [ ] **Step 2: Hand off to user**
The user validates solver behavior themselves (e.g. `uv run python solve.py` should produce the same plan/objective as before the change, now printing `objective E^2*B*S^2 = ...`).

View file

@ -0,0 +1,105 @@
# Configurable Maximize Criteria for `solve()`
**Date:** 2026-06-11
**Status:** Approved
## Problem
The objective in `solve.py` is hardcoded at the bottom of `solve()` (the
"OBJECTIVE IS SET HERE" block): it maximizes the monomial
`finalE² · finalB¹ · finalS²` via hand-chained `AddMultiplicationEquality`
calls, with intermediate bounds derived from the Phase-1 per-resource
ceilings. Changing the criteria means editing solver internals. The actual
usage pattern (per git history) is varying the per-resource powers, so the
criteria should be a parameter of `solve()`.
## API
Two new keyword-only arguments on `solve()`, with module-level defaults in
the PARAMETERS section (matching the existing `INITIAL` / `FIXED_CHOICES`
pattern):
```python
OBJECTIVE_FACTORS = {"E": 2, "B": 1, "S": 2} # missing keys = 0 (excluded)
OBJECTIVE_MODE = "product" # "product" or "sum"
def solve(*, ..., objective_factors=None, objective_mode=None):
```
- `None` for either argument falls back to the module-level default, the
same convention `resource_constraints` already uses.
- Defaults reproduce the current hardcoded objective (`E²·B·S²`) exactly.
- Valid factor keys: `"E"`, `"B"`, `"S"`, `"C"` (Capital becomes
targetable). Values are integers.
### Validation (fail fast with `ValueError`)
- Unknown key in `objective_factors`.
- All factors zero (or empty dict) — degenerate objective.
- `objective_mode` not in `{"product", "sum"}`.
- Non-integer factor values.
- Negative factor values **in product mode only**: a negative exponent
means division, which integer CP-SAT cannot express. Sum mode accepts
negative weights — CP-SAT handles negative coefficients in linear
objectives natively.
## Semantics
The factor means **exponent** in product mode and **weight** in sum mode.
- **`product`**: maximize `Πₖ finalₖ^factorₖ`. Built generically: expand the
factors to a flat list of final-resource vars (e.g.
`[finalE, finalE, finalB, finalS, finalS]`), then fold pairwise with
`AddMultiplicationEquality`, carrying a running upper bound multiplied
from the Phase-1 caps. This replaces the hand-chained `prodEE`/`prodSS`/
`prodBB`/`prodEB`/`obj` block and the `Eprod`/`Bprod`/`Sprod` bound
helpers.
- **`sum`**: maximize `Σₖ factorₖ · finalₖ` — a single `m.Maximize(...)` on
a linear expression; no auxiliary variables. May be negative when
negative weights are used.
A factor of 0 drops the resource from the objective (exponent 0 → factor
of 1 in product mode), matching current behavior where Capital simply
isn't in the objective. It does not force the resource to zero.
## Phase-1 ceilings
Ceilings (`_ceiling`) are computed only for resources with a nonzero
factor. Rationale:
- Each ceiling solve costs up to 20 s; skipping unused resources is a real
win.
- `C` gets a ceiling only when it appears in a product objective, where the
bound is required for the intermediate product variables.
- Sum mode does not need caps for bounds, but the redundant
`final ≤ cap` constraints are still added for computed ceilings since
they prune the search.
## Reporting
`_report` receives the resolved objective spec (factors + mode) and prints
the expression that was actually maximized along with its value, replacing
the hardcoded `product(scaled) = E·B·S` line. Examples:
- product mode: `objective E^2*B*S^2 = <value>` with the value descaled by
`10^(sum of exponents)`.
- sum mode: `objective 2E + B - S = <value>` descaled by 10.
`finalC` (`C[NUM_STEPS + 1]`) participates in the printout when `"C"` has a
nonzero factor; `_report` already receives the `C` pool dict. The
per-resource FINAL line and the intermediate solution printer (E/B/S)
remain unchanged.
## Unchanged
- The intermediate solution printer still tracks E/B/S.
- `web_solve.py` keeps working unmodified — both new arguments are
optional with behavior-preserving defaults.
## Bounds note
With `MAX_RES = 2000`, the default product bound is
`2000⁵ = 3.2 × 10¹⁶`, comfortably inside CP-SAT's int64 objective range.
Extreme factor values could overflow; the fold computes the running bound
explicitly, so an overflow would surface as a CP-SAT model error rather
than silent wraparound. No additional guard is included (YAGNI).

View file

@ -1,17 +0,0 @@
{
"turns": 5,
"start": {"capital": 3, "luxuries": 3, "steel": 3, "brass": 3, "electrum": 3},
"cities": [
{"name": "Aridias", "type": "hub", "renown": 2},
{"name": "Bearhearth", "type": "foundry", "renown": 2, "vat_steel": 3, "vat_brass": 2, "vat_electrum": 1},
{"name": "Kingsland", "type": "metropolis", "renown": 4, "can_renovate": false},
{"name": "Roseward", "type": "monument", "renown": 2}
],
"agents": [
{"name": "Planner", "overwork": true}
],
"objective": {
"mode": "linear",
"scalars": {"renown": 5, "capital": 1, "luxuries": 1, "steel": 1, "brass": 1, "electrum": 2, "trade_goods": 1, "express": 3}
}
}

1560
index.html

File diff suppressed because it is too large Load diff

460
main.py
View file

@ -1,460 +0,0 @@
"""Web UI for the Days Without Strife planner (see solve.py).
A dependency-free (stdlib only) HTTP server that exposes every input the
solver accepts: turns, starting resources, cities, agents, scoring terms
(linear or log), resource constraints and the misc Problem knobs.
For log-scored terms the user supplies a JavaScript expression that evals into
a single-argument function (e.g. ``(x) => Math.log2(x)``). The browser evals it
once, then calls it over the amounts it needs (0..max_resource) to build the
``log_mapping`` lookup table, which is sent to the server as a plain array.
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlsplit
from solve import problem_from_dict, solve, solution_to_dict
# The UI is a single static page served from index.html next to this module.
INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8")
# Completed solves are persisted to SQLite so they can be looked up later by
# their UUID (GET /solve/<uuid>) and shared via /?solve=<uuid> deep links. Point
# DB_PATH at a mounted volume so history survives container restarts/redeploys.
# Old rows are evicted so the DB stays bounded (keep the newest MAX_SOLVES).
DB_PATH = os.environ.get(
"DB_PATH", str(Path(__file__).resolve().parent / "data" / "solves.db"))
MAX_SOLVES = int(os.environ.get("MAX_SOLVES", "500"))
def _db():
conn = sqlite3.connect(DB_PATH, timeout=5.0)
# WAL lets the concurrent /solve/<uuid> readers run alongside a writer.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db():
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = _db()
try:
with conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS solves ("
"token TEXT PRIMARY KEY, ts REAL NOT NULL, status TEXT, "
"name TEXT, problem TEXT NOT NULL, solution TEXT NOT NULL)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_solves_ts ON solves(ts)")
# Add the custom-name column to pre-existing DBs that lack it.
cols = [r[1] for r in conn.execute("PRAGMA table_info(solves)")]
if "name" not in cols:
conn.execute("ALTER TABLE solves ADD COLUMN name TEXT")
finally:
conn.close()
# Custom display names for solves, keyed by token. A solve can be renamed while
# it is still queued/running (before its row exists), so names are tracked in
# memory and written into the row when the solve is stored; renaming an
# already-stored solve also updates the row directly (see _handle_rename).
_names = {}
_names_lock = threading.Lock()
def store_solve(token, problem, solution):
# token is client-generated; skip blanks. INSERT OR REPLACE means a repeated
# token overwrites (a client could clobber its own/another's row — acceptable
# for this app; switch to a server-issued id if that ever matters).
if not token:
return
with _names_lock:
name = _names.get(token)
conn = _db()
try:
with conn:
conn.execute(
"INSERT OR REPLACE INTO solves (token, ts, status, name, problem, solution) "
"VALUES (?, ?, ?, ?, ?, ?)",
(token, time.time(), solution.get("status"), name,
json.dumps(problem), json.dumps(solution)))
conn.execute(
"DELETE FROM solves WHERE token NOT IN "
"(SELECT token FROM solves ORDER BY ts DESC LIMIT ?)",
(MAX_SOLVES,))
finally:
conn.close()
def fetch_solve(token):
conn = _db()
try:
row = conn.execute(
"SELECT token, ts, status, name, problem, solution FROM solves WHERE token = ?",
(token,)).fetchone()
finally:
conn.close()
if row is None:
return None
return {"token": row[0], "ts": row[1], "status": row[2], "name": row[3],
"problem": json.loads(row[4]), "solution": json.loads(row[5])}
# Solves are queued and run one at a time by a single background worker, so any
# number of viewers can pile requests on without being rejected — each request
# returns immediately with a queue position and the client polls /job/<token>
# for progress. Everything below is guarded by _cond (a Condition whose lock
# also protects _jobs/_queue/_active); the worker waits on it for new work.
_cond = threading.Condition()
# token -> {"status", "problem", "raw_problem", "max_time", "error"}.
# status is one of: queued, running, done, error, cancelled.
_jobs = {}
# Tokens waiting to run, in order.
_queue = []
# The in-flight solve so /cancel can stop it. /cancel is hit two ways: the user
# clicking a card's Cancel button (a normal fetch, tab stays open) and a closing
# tab firing navigator.sendBeacon("/cancel?token=...") — both small requests
# that pass cleanly through reverse proxies. For a still-queued token /cancel
# just drops it from the queue; for the running token it stops the search.
_active = {"token": None, "solver": None}
# Bound the in-memory job map: keep at most this many terminal (done/error/
# cancelled) entries. Completed solves still live in SQLite, so a pruned "done"
# job degrades gracefully — /job falls back to the DB and still reports done.
MAX_TERMINAL_JOBS = 100
def _prune_jobs():
# Caller holds _cond. Drop the oldest terminal jobs beyond the cap.
terminal = [t for t, j in _jobs.items()
if j["status"] in ("done", "error", "cancelled")]
for t in terminal[:max(0, len(terminal) - MAX_TERMINAL_JOBS)]:
_jobs.pop(t, None)
def _solve_worker():
# Run queued solves one at a time, forever. Started as a daemon in main().
while True:
with _cond:
while not _queue:
_cond.wait()
token = _queue.pop(0)
job = _jobs.get(token)
if job is None or job["status"] != "queued":
continue # cancelled (or vanished) before it ran
job["status"] = "running"
job["started_at"] = time.time()
problem = job["problem"]
raw_problem = job["raw_problem"]
max_time = job["max_time"]
_active["token"] = token
_active["solver"] = None
_cond.notify_all() # wake /job_status streams watching this token
def register(s):
with _cond:
if _active["token"] == token:
_active["solver"] = s
try:
sol = solve(problem, max_time_seconds=max_time, solver_sink=register)
sol_dict = solution_to_dict(sol)
# Persist before flipping to "done" so a client that sees "done"
# can always fetch the solution. A cancelled-midway solve returns
# the best plan found so far and is stored like any other.
store_solve(token, raw_problem, sol_dict)
with _cond:
job["status"] = "done"
job["finished_at"] = time.time()
_cond.notify_all()
except Exception as exc:
with _cond:
job["status"] = "error"
job["finished_at"] = time.time()
job["error"] = f"{type(exc).__name__}: {exc}"
_cond.notify_all()
finally:
with _cond:
_active["token"] = None
_active["solver"] = None
def _estimate_start(token):
# Caller holds _cond. Estimate the wall-clock time (epoch seconds) at which a
# still-queued token will *begin* running: now, plus the running solve's
# remaining time budget, plus the full time budget of every queued solve
# ahead of it. Each solve's max_time is an upper bound (a search can stop
# early), so this is a worst-case "no later than" estimate.
now = time.time()
wait = 0.0
active_token = _active["token"]
if active_token:
aj = _jobs.get(active_token)
if aj is not None:
started = aj.get("started_at") or now
wait += max(0.0, aj["max_time"] - (now - started))
if token in _queue:
for ahead in _queue[:_queue.index(token)]:
j = _jobs.get(ahead)
if j is not None:
wait += j.get("max_time", 0.0)
return now + wait
def _stop_search(solver):
# StopSearch() exists in OR-Tools 9.x+; degrade gracefully on older builds
# (the solve then just runs to max_time_seconds).
stop = getattr(solver, "StopSearch", None)
if callable(stop):
stop()
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, content_type="application/json", no_cache=False):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
if no_cache:
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlsplit(self.path).path
if path in ("/", "/index.html"):
self._send(200, INDEX_HTML, "text/html; charset=utf-8")
elif path == "/job_status":
tokens = parse_qs(urlsplit(self.path).query).get("tokens", [""])[0]
self._handle_job_stream(tokens)
elif path.startswith("/job/"):
self._send(200, json.dumps(self._job_state(unquote(path[len("/job/"):]))),
no_cache=True)
elif path.startswith("/solve/"):
token = unquote(path[len("/solve/"):])
rec = fetch_solve(token)
if rec is None:
self._send(404, json.dumps({"error": "not found"}), no_cache=True)
else:
self._send(200, json.dumps(rec), no_cache=True)
else:
self._send(404, json.dumps({"error": "not found"}))
def _job_state(self, token):
# Report a queued/running solve's live state, falling back to SQLite for
# a finished (or evicted-from-memory) one. The client polls this to drive
# each pending card: queued -> running -> done (render) / error / cancelled.
# Timing carried through to terminal states so the UI can show when a
# solve actually began/finished (and how long it took).
timing = {}
# Read the custom name without holding _cond (kept separate to avoid any
# lock-ordering coupling between _names_lock and _cond).
with _names_lock:
name = _names.get(token)
with _cond:
job = _jobs.get(token)
if job is not None:
status = job["status"]
for k in ("started_at", "finished_at"):
if job.get(k) is not None:
timing[k] = job[k]
max_time = job.get("max_time")
if status == "queued":
pos = _queue.index(token) if token in _queue else 0
eta_start = _estimate_start(token)
return {"status": "queued", "position": pos,
"eta_start": eta_start,
"eta_finish": eta_start + (max_time or 0.0),
"max_time": max_time, "name": name}
if status == "running":
started = job.get("started_at")
return {"status": "running", "started_at": started,
"eta_finish": (started + max_time)
if (started and max_time) else None,
"max_time": max_time, "name": name}
if status == "error":
return {"status": "error", "error": job.get("error"),
"name": name, **timing}
if status == "cancelled":
return {"status": "cancelled", "name": name, **timing}
# status == "done": fall through to load the stored solution.
rec = fetch_solve(token)
if rec is not None:
return {"status": "done", "solution": rec["solution"],
"name": name or rec.get("name"), **timing}
return {"status": "unknown"}
# Statuses a job can't move on from — once a tracked token reaches one we
# send it a final time and stop watching it.
_TERMINAL = ("done", "error", "cancelled", "unknown")
def _handle_job_stream(self, tokens_csv):
# Server-Sent Events stream for one or more tokens
# (GET /job_status?tokens=t1,t2,…). Emits a `data:` event per token
# whenever its state changes (queued/position -> running -> done/…),
# blocking on _cond between changes rather than busy-polling, and drops
# each token once it's terminal; the stream closes when all are done.
remaining, seen = [], set()
for t in (s.strip() for s in tokens_csv.split(",")):
if t and t not in seen:
seen.add(t)
remaining.append(t)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
# Tell nginx & friends not to buffer the stream (see reverse-proxy notes).
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
last = {}
try:
while remaining:
# Send any token whose state changed since we last reported it,
# and stop tracking the ones that have reached a terminal state.
for token in list(remaining):
state = self._job_state(token)
payload = json.dumps({"token": token, **state})
if last.get(token) != payload:
last[token] = payload
self.wfile.write(b"data: " + payload.encode("utf-8") + b"\n\n")
self.wfile.flush()
if state["status"] in self._TERMINAL:
remaining.remove(token)
if not remaining:
break
# Block until a job changes state (worker/cancel call notify_all);
# on the periodic timeout send a comment as a keep-alive heartbeat.
with _cond:
notified = _cond.wait(timeout=15)
if not notified:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
return # client closed the EventSource; let the thread end
def do_POST(self):
path = urlsplit(self.path)
if path.path == "/cancel":
self._handle_cancel(parse_qs(path.query))
return
if path.path == "/rename":
self._handle_rename()
return
if path.path != "/solve":
self._send(404, json.dumps({"error": "not found"}))
return
# Validate and enqueue, then return immediately with a queue position.
# The single background worker runs queued solves one at a time; the
# client follows progress over /job_status (SSE) or by polling /job/<token>.
try:
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
raw_problem = payload.get("problem", {})
problem = problem_from_dict(raw_problem) # surfaces bad input now
max_time = float(payload.get("max_time_seconds", 30.0))
token = str(payload.get("token") or "")
if not token:
self._send(400, json.dumps({"error": "missing token"}))
return
with _cond:
_prune_jobs()
_jobs[token] = {
"status": "queued", "problem": problem,
"raw_problem": raw_problem, "max_time": max_time, "error": None,
}
_queue.append(token)
position = len(_queue) - 1
_cond.notify_all()
self._send(202, json.dumps({"status": "queued", "position": position}),
no_cache=True)
except Exception as exc: # surface errors to the browser
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
def _handle_rename(self):
# Set (or clear) a solve's custom display name. Works whether the solve
# is still queued/running (name kept in memory, applied when stored) or
# already stored (the row is updated too). An empty name clears it,
# reverting the card to its default "Solution n" label.
try:
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
except Exception as exc:
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
return
token = str(payload.get("token") or "")
raw = payload.get("name")
name = (str(raw).strip() or None) if raw is not None else None
if not token:
self._send(400, json.dumps({"error": "missing token"}))
return
with _names_lock:
if name is None:
_names.pop(token, None)
else:
_names[token] = name
# Persist onto the stored row if the solve has already been saved.
conn = _db()
try:
with conn:
conn.execute("UPDATE solves SET name = ? WHERE token = ?", (name, token))
finally:
conn.close()
# Push the change to anyone watching this token's status stream.
with _cond:
_cond.notify_all()
self._send(200, json.dumps({"ok": True, "name": name}), no_cache=True)
def _handle_cancel(self, query):
# Cancel iff the request's token matches a queued/running solve, so a
# Cancel click (or closing tab) can't cancel a *different* viewer's
# solve. A still-queued token is simply dropped from the queue; the
# running token has its search stopped (the worker then stores the best
# plan found so far and the job flips to "done").
token = (query.get("token") or [""])[0]
result = "none"
with _cond:
if token and token in _queue:
_queue.remove(token)
job = _jobs.get(token)
if job is not None:
job["status"] = "cancelled"
job["finished_at"] = time.time()
result = "dequeued"
_cond.notify_all()
elif token and token == _active["token"] and _active["solver"] is not None:
_stop_search(_active["solver"])
result = "stopped"
self._send(200, json.dumps({"cancelled": result}), no_cache=True)
def log_message(self, fmt, *args): # quieter console
pass
def main():
init_db()
threading.Thread(target=_solve_worker, daemon=True).start()
host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8000"))
server = ThreadingHTTPServer((host, port), Handler)
print(f"Days Without Strife planner UI: http://{host}:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nshutting down")
server.shutdown()
if __name__ == "__main__":
main()

21
printer.py Normal file
View file

@ -0,0 +1,21 @@
from ortools.sat.python import cp_model
class IntermediateSolutionPrinter(cp_model.CpSolverSolutionCallback):
"""Callback that prints intermediate solutions."""
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.scale * self.Value(var)}")
@property
def solution_count(self):
return self._solution_count

View file

@ -1,9 +1,10 @@
[project]
name = "dws-solve"
name = "solve"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"flask>=3.1.3",
"ortools>=9.15.6755",
]

3621
solve.py

File diff suppressed because it is too large Load diff

913
templates/solver.html Normal file
View file

@ -0,0 +1,913 @@
<!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 {
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 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>
<!-- 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>
<!-- Objective Section -->
<div class="form-section">
<h2>Objective</h2>
<p style="color: #666; font-size: 13px; margin-bottom: 15px;">
Factor = exponent in product mode, weight in sum mode. A factor of 0 excludes that
resource from the objective (it is not forced to zero). Negative factors are only
allowed in sum mode. Factors are unaffected by the internal x10 resource scaling.
</p>
<div class="form-group">
<label for="objective_mode">Mode</label>
<select id="objective_mode" name="objective_mode">
<option value="product" {% if objective_mode=="product" %}selected{% endif %}>Product
(maximize E^a &times; B^b &times; ...)</option>
<option value="sum" {% if objective_mode=="sum" %}selected{% endif %}>Sum
(maximize a&middot;E + b&middot;B + ...)</option>
</select>
</div>
<div class="resource-inputs">
<div class="form-group">
<label for="objective_factor_E">Electrum Factor</label>
<input type="number" id="objective_factor_E" name="objective_factor_E"
value="{{ objective_factors.get('E', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_B">Brass Factor</label>
<input type="number" id="objective_factor_B" name="objective_factor_B"
value="{{ objective_factors.get('B', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_S">Steel Factor</label>
<input type="number" id="objective_factor_S" name="objective_factor_S"
value="{{ objective_factors.get('S', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_C">Capital Factor</label>
<input type="number" id="objective_factor_C" name="objective_factor_C"
value="{{ objective_factors.get('C', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_R">Renown Factor</label>
<input type="number" id="objective_factor_R" name="objective_factor_R"
value="{{ objective_factors.get('R', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_L">Luxuries Factor</label>
<input type="number" id="objective_factor_L" name="objective_factor_L"
value="{{ objective_factors.get('L', 0) }}" step="1">
</div>
<div class="form-group">
<label for="objective_factor_X">Express tickets Factor</label>
<input type="number" id="objective_factor_X" name="objective_factor_X"
value="{{ objective_factors.get('X', 0) }}" step="1">
</div>
</div>
</div>
<!-- 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>.
Resources: E (Electrum), B (Brass), S (Steel), C (Capital), R (Renown),
L (Luxuries), X (Express tickets)
</p>
<p style="color: red; font-size:13px;">Warning: all resources are multiplied by 10 internally so
constraints should also be multiplied by 10 e.g. <code>E[2]&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="checkbox-group" style="margin-bottom: 15px;">
<input type="checkbox" id="city_${id}_base" name="city_${id}_base">
<label for="city_${id}_base" style="margin: 0;">Base (Provisioner bonus applies here)</label>
</div>
<div class="form-group">
<label for="city_${id}_adjacent">Adjacent Cities</label>
<input type="text" id="city_${id}_adjacent" name="city_${id}_adjacent" placeholder="e.g., 0,2,3" class="agent-steps-input">
</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;
});
// Convert per-city Base checkboxes to true/false
const baseCheckboxes = form.querySelectorAll('input[name^="city_"][name$="_base"][type="checkbox"]');
baseCheckboxes.forEach(input => {
data[input.name] = input.checked;
});
// Collect objective mode and factors (only nonzero factors; missing = excluded)
data.objective_mode = document.getElementById('objective_mode').value;
const objectiveFactors = {};
['E', 'B', 'S', 'C', 'R', 'L', 'X'].forEach(r => {
const factor = parseInt(document.getElementById(`objective_factor_${r}`).value);
if (factor) {
objectiveFactors[r] = factor;
}
});
data.objective_factors = objectiveFactors;
// 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>

150
uv.lock
View file

@ -20,15 +20,51 @@ wheels = [
]
[[package]]
name = "dws-solve"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "ortools" },
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.metadata]
requires-dist = [{ name = "ortools", specifier = ">=9.15.6755" }]
[[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"
@ -39,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"
@ -198,6 +307,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "solve"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "ortools" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=3.1.3" },
{ name = "ortools", specifier = ">=9.15.6755" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@ -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" },
]

205
web_solve.py Normal file
View file

@ -0,0 +1,205 @@
"""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,
objective_factors=solve.OBJECTIVE_FACTORS,
objective_mode=solve.OBJECTIVE_MODE,
)
@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)
# R = Renown, L = Luxuries, X = Express tickets (default 0; no sources yet)
initial_defaults = {"E": 3, "B": 3, "S": 3, "C": 3, "R": 0, "L": 0, "X": 0}
initial = tuple(
int(float(data.get(f"initial_{r}", d)) * 10)
for r, d in initial_defaults.items()
)
# 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()
]
# Parse base flag (checkbox-style: may arrive as bool or string)
base_val = data.get(f"city_{city_idx}_base", False)
if isinstance(base_val, str):
base_val = base_val.lower() in ("true", "on", "1", "yes")
city_data = {
"type": city_type,
"adjacent_to": adjacent_to,
"base": bool(base_val),
"departure_step": departure_step,
}
# 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] = []
solve.AGENT_AVAILABILITY = agent_availability
# 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 objective (factor = exponent in "product" mode, weight in
# "sum" mode; missing keys = resource excluded). Factors are NOT x10
# scaled — they apply to the scaled resource totals and the report
# descales the objective. None falls back to solve.py's defaults.
objective_mode = data.get("objective_mode") or None
objective_factors = None
objective_factors_data = data.get("objective_factors")
if objective_factors_data is not None:
# All-zero dict passes through so solve()'s validation rejects it
# with a clear error instead of silently using the defaults.
objective_factors = {r: int(f) for r, f in objective_factors_data.items()}
# 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),
time_limit=time_limit,
num_workers=8,
verbose=verbose,
fixed_choices=fixed_choices,
resource_constraints=resource_constraints,
objective_factors=objective_factors,
objective_mode=objective_mode,
)
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)