Compare commits
No commits in common. "main" and "new-main" have entirely different histories.
18 changed files with 3954 additions and 3843 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
.serena
|
||||
__pycache__
|
||||
output.txt
|
||||
.worktrees
|
||||
.venv
|
||||
*.pdf
|
||||
*.db
|
||||
|
|
|
|||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/cache
|
||||
/project.local.yml
|
||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# 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 read‑only.
|
||||
# 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: []
|
||||
22
Dockerfile
22
Dockerfile
|
|
@ -5,19 +5,19 @@ WORKDIR /app
|
|||
# Install uv
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
# Copy project files
|
||||
# Install dependencies first (cached unless the lockfile changes)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install dependencies with uv
|
||||
RUN uv sync --no-editable
|
||||
RUN uv sync --frozen --no-install-project
|
||||
|
||||
# Copy source code
|
||||
COPY solve.py web_solve.py ./
|
||||
COPY templates/ templates/
|
||||
COPY solve.py main.py index.html ./
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=web_solve.py
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
# Bind to all interfaces so the server is reachable outside the container
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=8000 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Run Flask app
|
||||
CMD ["uv", "run", "python", "web_solve.py"]
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the stdlib HTTP server
|
||||
CMD ["uv", "run", "--frozen", "python", "main.py"]
|
||||
|
|
|
|||
157
README.md
157
README.md
|
|
@ -1,25 +1,154 @@
|
|||
# DWS city resource solver
|
||||
# dws-solve
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
`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.
|
||||
## What it optimizes
|
||||
|
||||
`printer.py` provides some code for verbose logging in `solve.py`.
|
||||
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,
|
||||
|
||||
## WARNING: Vibe Coded
|
||||
it finds the sequence of per-city Industry Actions (Collect / Renovate /
|
||||
Upgrade / Launch Airship / Idle) that maximizes the objective.
|
||||
|
||||
If you see something nonsensical that's why.
|
||||
Objective is one of two forms over the resources
|
||||
*Renown, Luxuries, Capital, Steel, Brass, Electrum, Trade Goods, Express tickets*:
|
||||
|
||||
For me this work is disposable so issues with quality resulting from LLM usage is fine.
|
||||
- **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).
|
||||
|
||||
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.
|
||||
"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.
|
||||
|
||||
## Running
|
||||
## Usage
|
||||
|
||||
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.
|
||||
### As a CLI (JSON in / JSON out)
|
||||
|
||||
You'll need Flask if you wanna run the web UI.
|
||||
```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
|
||||
```
|
||||
|
||||
If you're lazy and not choosing docker you can use uv to run this.
|
||||
### 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.
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
"""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__": {}})
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "5555:5000"
|
||||
- "5555:8000"
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
- FLASK_DEBUG=1
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
# Stored solves live on the mounted volume so they survive restarts.
|
||||
- DB_PATH=/data/solves.db
|
||||
volumes:
|
||||
- .:/app
|
||||
command: uv run python web_solve.py
|
||||
- solves:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
solves:
|
||||
|
|
|
|||
|
|
@ -1,311 +0,0 @@
|
|||
# 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 = ...`).
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
# 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).
|
||||
17
example.json
Normal file
17
example.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"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
Normal file
1560
index.html
Normal file
File diff suppressed because it is too large
Load diff
460
main.py
Normal file
460
main.py
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
"""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
21
printer.py
|
|
@ -1,21 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
[project]
|
||||
name = "solve"
|
||||
name = "dws-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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,913 +0,0 @@
|
|||
<!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 × B^b × ...)</option>
|
||||
<option value="sum" {% if objective_mode=="sum" %}selected{% endif %}>Sum
|
||||
(maximize a·E + b·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]>=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>
|
||||
148
uv.lock
148
uv.lock
|
|
@ -20,51 +20,15 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
name = "dws-solve"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
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" },
|
||||
{ name = "ortools" },
|
||||
]
|
||||
|
||||
[[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.metadata]
|
||||
requires-dist = [{ name = "ortools", specifier = ">=9.15.6755" }]
|
||||
|
||||
[[package]]
|
||||
name = "immutabledict"
|
||||
|
|
@ -75,79 +39,6 @@ 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"
|
||||
|
|
@ -307,21 +198,6 @@ 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"
|
||||
|
|
@ -339,15 +215,3 @@ 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
205
web_solve.py
|
|
@ -1,205 +0,0 @@
|
|||
"""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)
|
||||
Loading…
Reference in a new issue