Compare commits
No commits in common. "main" and "df0d42b2ec29535272ff0e42534fbb3947538c8e" have entirely different histories.
main
...
df0d42b2ec
18 changed files with 2592 additions and 3846 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
.serena
|
|
||||||
__pycache__
|
__pycache__
|
||||||
output.txt
|
.venv
|
||||||
.worktrees
|
*.pdf
|
||||||
|
|
|
||||||
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
|
# Install uv
|
||||||
RUN pip install --no-cache-dir uv
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
# Copy project files
|
# Install dependencies first (cached unless the lockfile changes)
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-install-project
|
||||||
# Install dependencies with uv
|
|
||||||
RUN uv sync --no-editable
|
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY solve.py web_solve.py ./
|
COPY solve.py main.py index.html ./
|
||||||
COPY templates/ templates/
|
|
||||||
|
|
||||||
# Set environment variables
|
# Bind to all interfaces so the server is reachable outside the container
|
||||||
ENV FLASK_APP=web_solve.py
|
ENV HOST=0.0.0.0 \
|
||||||
ENV PYTHONUNBUFFERED=1
|
PORT=8000 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Run Flask app
|
EXPOSE 8000
|
||||||
CMD ["uv", "run", "python", "web_solve.py"]
|
|
||||||
|
# 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.
|
A planner optimizer for the **Days Without Strife** Faction *Planner* role,
|
||||||
See `solve.py` for details.
|
built on Google OR-Tools (CP-SAT). It plans Industry Actions across the game's
|
||||||
`web_solve.py` provides a web UI to churn through various scenarios more easily.
|
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,9 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "5555:5000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=development
|
- HOST=0.0.0.0
|
||||||
- FLASK_DEBUG=1
|
- PORT=8000
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
command: uv run python web_solve.py
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
854
index.html
Normal file
854
index.html
Normal file
|
|
@ -0,0 +1,854 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Days Without Strife - Planner</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 100vw;
|
||||||
|
margin-inline: auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: 1px solid #8884;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
padding: .2rem .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=number] {
|
||||||
|
width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: .3rem .7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #8886;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #8881;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
padding: .5rem 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
padding: .1rem .45rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: .6rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field>span {
|
||||||
|
font-size: .8rem;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #8881;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
color: #dc2626;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
font-size: .8rem;
|
||||||
|
opacity: .75;
|
||||||
|
margin: .2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each input "row" is a card whose labeled fields flow in an auto-fit grid,
|
||||||
|
so wide rows wrap onto multiple lines instead of overflowing the page. */
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid #8884;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .55rem .7rem;
|
||||||
|
display: grid;
|
||||||
|
gap: .45rem .8rem;
|
||||||
|
align-items: start;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-align cells but keep every field's input on a common baseline by
|
||||||
|
reserving a uniform (two-line) label height — so a wrapping label no
|
||||||
|
longer drops its input below the others, and a stacked cell (Type +
|
||||||
|
"Can renovate") can hang its extra control underneath while its primary
|
||||||
|
input still lines up with the rest. */
|
||||||
|
.card .field:not(.check):not(.vat-row)>span {
|
||||||
|
min-height: 2.8em;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card input,
|
||||||
|
.card select,
|
||||||
|
.card textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card input[type=checkbox] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack two controls vertically inside a single grid cell (e.g. the city
|
||||||
|
Type field with its "Can renovate" checkbox tucked underneath). Top-align
|
||||||
|
so the Type input stays on the same row as the card's other inputs while
|
||||||
|
the checkbox hangs below. */
|
||||||
|
.card .stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .45rem;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vat amounts: stacked rows, each with its label to the left of the input. */
|
||||||
|
.card .vat-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .3rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .vat-head {
|
||||||
|
font-size: .8rem;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .vat-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .vat-row>span {
|
||||||
|
flex: 0 0 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .vat-row input {
|
||||||
|
width: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
align-self: start;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the "Log" checkbox and its JS-function field together in one cell
|
||||||
|
and reserve the space up front, so enabling the field only fills the
|
||||||
|
already-allotted room instead of reflowing the whole card. */
|
||||||
|
.card .log-group {
|
||||||
|
grid-column: span 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
gap: .5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-cell {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.log-on .log-cell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Output tables: a CSS grid kept inside a scroll container so it never
|
||||||
|
stretches the page wider than it should. */
|
||||||
|
.gtable-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtable {
|
||||||
|
display: grid;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtable>div {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
border-bottom: 1px solid #8883;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtable>.gh {
|
||||||
|
font-size: .85rem;
|
||||||
|
opacity: .8;
|
||||||
|
border-bottom: 1px solid #8886;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solution cards: each solve gets its own collapsible block so older
|
||||||
|
solutions stay viewable, newest on top. */
|
||||||
|
details.solution {
|
||||||
|
border: 1px solid #8884;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .3rem .8rem;
|
||||||
|
margin-top: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.solution>summary {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: .3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
opacity: .75;
|
||||||
|
margin-top: .8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Days Without Strife — Planner Optimizer</h1>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Game</legend>
|
||||||
|
<div class="grid">
|
||||||
|
<label class="field"><span>Turns</span><input id="turns" type="number" min="1" value="5"
|
||||||
|
oninput="refreshTurnSelects()"></label>
|
||||||
|
<label class="field"><span>Extra renown (constant)</span><input id="extra_renown" type="number"
|
||||||
|
value="0"></label>
|
||||||
|
<label class="field"><span>Airships already launched</span><input id="airships_launched" type="number"
|
||||||
|
min="0" value="0"></label>
|
||||||
|
<label class="field"><span>Max resource</span><input id="max_resource" type="number" min="1"
|
||||||
|
value="300"></label>
|
||||||
|
<label class="field"><span>Max vat</span><input id="max_vat" type="number" min="1" value="12"></label>
|
||||||
|
<label class="field"><span>Solver time limit (s)</span><input id="time" type="number" min="1"
|
||||||
|
value="30"></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Starting resources</legend>
|
||||||
|
<div class="grid" id="start"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Tradeable into (Trade Goods convert 1-for-1 for scoring)</legend>
|
||||||
|
<div id="tradeable"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Cities</legend>
|
||||||
|
<p class="help">The <b>name</b> is the city's unique identifier — it labels the
|
||||||
|
output plan and is what an agent's "forced city" refers to. <b>Upgrades</b> are the
|
||||||
|
ones already installed at the start; the available choices follow the city's type.
|
||||||
|
<b>Adjacent cities</b> lists neighbours by name (adjacency is symmetric); an
|
||||||
|
Industrialist Governor grants free Infrastructure to its city and every adjacent one.
|
||||||
|
</p>
|
||||||
|
<div id="cities" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addCity()">+ city</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Agents</legend>
|
||||||
|
<p class="help">Pick a known agent; its governor behaviour is implicit and
|
||||||
|
shown in the <b>Effect</b> column. The <b>name</b> identifies it and is what a
|
||||||
|
forced city refers back to. All effects apply while the agent is Governor of a
|
||||||
|
city (at most one city per turn).</p>
|
||||||
|
<div id="agents" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addAgent()">+ agent</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Objective — scoring terms</legend>
|
||||||
|
<p class="help">Each term scores a resource (or <code>renown</code>) at the end of a
|
||||||
|
turn (blank turn = final turn). For a <b>log</b> term, write a JS expression that
|
||||||
|
evals to a one-argument function, e.g. <code>(x) => Math.log2(x + 1)</code>.
|
||||||
|
It is called over amounts <code>0..max_resource</code> in the browser to build the
|
||||||
|
lookup table.</p>
|
||||||
|
<div id="terms" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addTerm()">+ term</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Resource constraints</legend>
|
||||||
|
<div id="constraints" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button class="primary" type="button" onclick="run()">Solve</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="error" class="err"></div>
|
||||||
|
<div id="output"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const RESOURCES = ["capital", "luxuries", "steel", "brass", "electrum", "trade_goods", "express"];
|
||||||
|
const SCORE_KEYS = RESOURCES.concat(["renown"]);
|
||||||
|
const CITY_TYPES = ["hub", "foundry", "monument", "metropolis"];
|
||||||
|
const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"];
|
||||||
|
// Upgrades that apply to any city type, plus the type-specific "3rd" upgrade.
|
||||||
|
const UNIVERSAL_UPGRADES = ["infrastructure", "harvester", "fortification"];
|
||||||
|
const TYPE_UPGRADE = {
|
||||||
|
hub: "fine_dining", foundry: "overflow_vats",
|
||||||
|
metropolis: "transit_authority", monument: "propaganda"
|
||||||
|
};
|
||||||
|
// Known agents and the implicit logic each one carries.
|
||||||
|
const AGENT_PRESETS = {
|
||||||
|
Planner: {overwork: true},
|
||||||
|
Baron: {bonus_trade_goods: 3}, // +N Trade Goods per Bastion on collect
|
||||||
|
Builder: {free_upgrade: true},
|
||||||
|
Capitalist: {bonus_capital: 2}, // +2 Capital on collect
|
||||||
|
Vinter: {bonus_luxuries: 2}, // +2 Luxuries on collect
|
||||||
|
Artificer: {bonus_trade_goods: 1}, // +1 Trade Good on collect
|
||||||
|
Metallurgist: {grants_overflow_vats: true}, // Overflow Vats on a Foundry
|
||||||
|
Industrialist: {grants_infrastructure: true}, // free Infrastructure
|
||||||
|
Foreman: {free_renovate: true}, // Renovate without spending the Action
|
||||||
|
Prodigy: {steel_refund: 2}, // refund <=2 Steel on Upgrade/Launch
|
||||||
|
Provisioner: {governor_electrum_half: 3}, // +1.5 Electrum / governing turn
|
||||||
|
Courier: {onetime_governor_bonus: {capital: 3, steel: 3, brass: 3}},
|
||||||
|
};
|
||||||
|
// Short human-readable effect, shown inline next to each agent's dropdown.
|
||||||
|
const AGENT_DESC = {
|
||||||
|
Planner: "Overwork: double Collection & waive Capital cost; locks next-turn Collect",
|
||||||
|
Baron: "+N Trade Goods per Bastion on Collect",
|
||||||
|
Builder: "free type-specific Upgrade",
|
||||||
|
Capitalist: "+2 Capital on Collect",
|
||||||
|
Vinter: "+2 Luxuries on Collect",
|
||||||
|
Artificer: "+1 Trade Good on Collect",
|
||||||
|
Metallurgist: "Overflow Vats effect on a Foundry",
|
||||||
|
Industrialist: "free Infrastructure Upgrade",
|
||||||
|
Foreman: "Renovate without spending the Action",
|
||||||
|
Prodigy: "refund ≤2 Steel on Upgrade/Launch",
|
||||||
|
Provisioner: "+1.5 Electrum per governing Turn",
|
||||||
|
Courier: "one-time +3 Capital/Steel/Brass on first govern",
|
||||||
|
};
|
||||||
|
|
||||||
|
function el(tag, attrs = {}, children = []) {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === "class") e.className = v;
|
||||||
|
else if (k === "html") e.innerHTML = v;
|
||||||
|
else if (k.startsWith("on")) e[k] = v;
|
||||||
|
else e.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of [].concat(children)) e.append(c);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function num(value, attrs = {}) {return el("input", {type: "number", value, ...attrs});}
|
||||||
|
function selectEl(opts, value) {
|
||||||
|
const s = el("select");
|
||||||
|
for (const o of opts) {
|
||||||
|
const opt = el("option", {value: o}, o);
|
||||||
|
if (o === value) opt.selected = true;
|
||||||
|
s.append(opt);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function removeBtn(row) {
|
||||||
|
return el("button", {
|
||||||
|
class: "mini", type: "button",
|
||||||
|
onclick: () => row.remove()
|
||||||
|
}, "×");
|
||||||
|
}
|
||||||
|
// A labeled field inside a card (label stacked above its control).
|
||||||
|
function field(label, control) {
|
||||||
|
return el("label", {class: "field"}, [el("span", {}, label), control]);
|
||||||
|
}
|
||||||
|
// A checkbox field (checkbox beside its label, laid out horizontally).
|
||||||
|
function checkField(label, cb) {
|
||||||
|
return el("label", {class: "field check"}, [cb, el("span", {}, label)]);
|
||||||
|
}
|
||||||
|
// Build an output grid "table": a CSS grid (one column per header) wrapped in
|
||||||
|
// a horizontally-scrollable container so it never widens the page.
|
||||||
|
function gridTable(headers, rows) {
|
||||||
|
const wrap = el("div", {class: "gtable-wrap"});
|
||||||
|
const g = el("div", {
|
||||||
|
class: "gtable",
|
||||||
|
style: `grid-template-columns: repeat(${headers.length}, auto)`
|
||||||
|
});
|
||||||
|
for (const h of headers) g.append(el("div", {class: "gh"}, String(h)));
|
||||||
|
for (const r of rows)
|
||||||
|
for (const c of r) g.append(el("div", {}, String(c)));
|
||||||
|
wrap.append(g);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- turn dropdowns -------------------------------------------------------
|
||||||
|
// Turns are chosen from the current "Turns" count rather than typed freehand.
|
||||||
|
// Every turn <select> registers here so its options can be rebuilt (preserving
|
||||||
|
// the current selection) whenever the Turns count changes. value "" = final.
|
||||||
|
const turnSelects = new Set();
|
||||||
|
function turnsCount() {return +document.getElementById("turns").value || 0;}
|
||||||
|
function fillTurnOptions(sel) {
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
sel.append(el("option", {value: ""}, "final"));
|
||||||
|
for (let t = 0; t < turnsCount(); t++)
|
||||||
|
sel.append(el("option", {value: String(t)}, "turn " + t));
|
||||||
|
// Keep the prior choice if it still exists, else fall back to "final".
|
||||||
|
sel.value = [...sel.options].some(o => o.value === prev) ? prev : "";
|
||||||
|
}
|
||||||
|
function turnSelect(value) {
|
||||||
|
const sel = el("select");
|
||||||
|
turnSelects.add(sel);
|
||||||
|
fillTurnOptions(sel);
|
||||||
|
if (value !== undefined && value !== null && value !== "") sel.value = String(value);
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
function refreshTurnSelects() {
|
||||||
|
for (const sel of turnSelects) {
|
||||||
|
if (!sel.isConnected) {turnSelects.delete(sel); continue;}
|
||||||
|
fillTurnOptions(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- starting resources & tradeable ---
|
||||||
|
const startInputs = {};
|
||||||
|
const tradeInputs = {};
|
||||||
|
for (const r of RESOURCES) {
|
||||||
|
const inp = num(r === "express" || r === "trade_goods" ? 0 : 3, {min: 0});
|
||||||
|
startInputs[r] = inp;
|
||||||
|
document.getElementById("start").append(
|
||||||
|
el("label", {class: "field"}, [el("span", {}, r), inp]));
|
||||||
|
|
||||||
|
const cb = el("input", {type: "checkbox"});
|
||||||
|
if (r !== "express") cb.checked = true;
|
||||||
|
tradeInputs[r] = cb;
|
||||||
|
document.getElementById("tradeable").append(
|
||||||
|
el("label", {style: "margin-right:1rem"}, [cb, " " + r]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cities ---
|
||||||
|
// Pick a name not already used by an existing city: the lowest non-negative
|
||||||
|
// integer (as a string) that's free, matching the seeded "0", "1", … names.
|
||||||
|
function uniqueCityName() {
|
||||||
|
const taken = new Set(
|
||||||
|
[...document.getElementById("cities").children].map(r => r._get().name));
|
||||||
|
for (let n = 0; ; n++) if (!taken.has(String(n))) return String(n);
|
||||||
|
}
|
||||||
|
function addCity(c = {}) {
|
||||||
|
const card = el("div", {class: "card"});
|
||||||
|
const name = el("input", {value: c.name || uniqueCityName()});
|
||||||
|
const type = selectEl(CITY_TYPES, c.type || "hub");
|
||||||
|
const renown = num(c.renown ?? "", {min: 1, placeholder: "auto"});
|
||||||
|
const vs = num(c.vat_steel || 1, {min: 0}), vb = num(c.vat_brass || 1, {min: 0}),
|
||||||
|
ve = num(c.vat_electrum || 1, {min: 0});
|
||||||
|
const reno = el("input", {type: "checkbox"}); reno.checked = c.can_renovate !== false;
|
||||||
|
const adjacent = el("input", {
|
||||||
|
value: (c.adjacent || []).join(", "),
|
||||||
|
placeholder: "Bearhearth, Kingsland"
|
||||||
|
});
|
||||||
|
const forced = el("input", {value: "", placeholder: "0:upgrade"});
|
||||||
|
const avail = el("input", {value: "", placeholder: ""});
|
||||||
|
|
||||||
|
// Upgrade checkboxes: the 3 universal upgrades plus the current type's
|
||||||
|
// type-specific upgrade. State persists across type changes (hidden boxes
|
||||||
|
// are simply ignored), and only the currently-allowed checked ones count.
|
||||||
|
const preset = new Set(c.upgrades || []);
|
||||||
|
const upBoxes = {};
|
||||||
|
const upWrap = el("div");
|
||||||
|
function allowedUpgrades() {
|
||||||
|
return UNIVERSAL_UPGRADES.concat([TYPE_UPGRADE[type.value]]);
|
||||||
|
}
|
||||||
|
function renderUpgrades() {
|
||||||
|
upWrap.innerHTML = "";
|
||||||
|
for (const u of allowedUpgrades()) {
|
||||||
|
if (!upBoxes[u]) {
|
||||||
|
const cb = el("input", {type: "checkbox"});
|
||||||
|
if (preset.has(u)) cb.checked = true;
|
||||||
|
upBoxes[u] = cb;
|
||||||
|
}
|
||||||
|
upWrap.append(el("label", {style: "display:block;font-size:.85rem"},
|
||||||
|
[upBoxes[u], " " + u]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Vat amounts only matter for Foundries; hide them otherwise and keep
|
||||||
|
// them as the card's last field (before the actions). They stack in a
|
||||||
|
// single cell, each input labeled on its left.
|
||||||
|
const vatRow = (label, input) =>
|
||||||
|
el("label", {class: "field vat-row"}, [el("span", {}, label), input]);
|
||||||
|
const vatGroup = el("div", {class: "vat-group"}, [
|
||||||
|
el("span", {class: "vat-head"}, "Vat amounts"),
|
||||||
|
vatRow("steel", vs),
|
||||||
|
vatRow("brass", vb),
|
||||||
|
vatRow("electrum", ve),
|
||||||
|
]);
|
||||||
|
function renderVats() {
|
||||||
|
vatGroup.style.display = type.value === "foundry" ? "" : "none";
|
||||||
|
}
|
||||||
|
type.onchange = () => {renderUpgrades(); renderVats();};
|
||||||
|
renderUpgrades();
|
||||||
|
renderVats();
|
||||||
|
|
||||||
|
card._get = () => {
|
||||||
|
const o = {name: name.value, type: type.value};
|
||||||
|
if (renown.value !== "") o.renown = +renown.value;
|
||||||
|
if (+vs.value) o.vat_steel = +vs.value;
|
||||||
|
if (+vb.value) o.vat_brass = +vb.value;
|
||||||
|
if (+ve.value) o.vat_electrum = +ve.value;
|
||||||
|
const u = allowedUpgrades().filter(name => upBoxes[name] && upBoxes[name].checked);
|
||||||
|
if (u.length) o.upgrades = u;
|
||||||
|
if (!reno.checked) o.can_renovate = false;
|
||||||
|
const adj = parseStrs(adjacent.value);
|
||||||
|
if (adj) o.adjacent = adj;
|
||||||
|
const fa = parsePairs(forced.value);
|
||||||
|
if (Object.keys(fa).length) o.forced_action = fa;
|
||||||
|
const at = parseInts(avail.value);
|
||||||
|
if (at) o.available_turns = at;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
card.append(
|
||||||
|
field("Name", name),
|
||||||
|
el("div", {class: "stack"}, [
|
||||||
|
field("Type", type),
|
||||||
|
checkField("Can renovate", reno),
|
||||||
|
]),
|
||||||
|
field("Renown", renown),
|
||||||
|
field("Upgrades (already installed)", upWrap),
|
||||||
|
field("Adjacent cities (csv of names)", adjacent),
|
||||||
|
field("Forced actions (turn:action, csv)", forced),
|
||||||
|
field("Avail turns (csv, blank=all)", avail),
|
||||||
|
vatGroup,
|
||||||
|
el("div", {class: "card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("cities").append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- agents ---
|
||||||
|
function addAgent(a = {}) {
|
||||||
|
const card = el("div", {class: "card"});
|
||||||
|
// Identify the agent by its preset flags; the logic is implicit to the choice.
|
||||||
|
let kind = a.kind;
|
||||||
|
if (!kind) {
|
||||||
|
kind = Object.keys(AGENT_PRESETS).find(
|
||||||
|
k => Object.keys(AGENT_PRESETS[k]).some(f => a[f] !== undefined)) || "Planner";
|
||||||
|
}
|
||||||
|
const type = selectEl(Object.keys(AGENT_PRESETS), kind);
|
||||||
|
const name = el("input", {value: a.name || kind});
|
||||||
|
const desc = el("span", {class: "help"});
|
||||||
|
const bastions = num(a.bonus_trade_goods || 3, {min: 0});
|
||||||
|
const forced = el("input", {value: "", placeholder: "0:Aridias"});
|
||||||
|
const avail = el("input", {value: ""});
|
||||||
|
|
||||||
|
const bastionsField = field("Bastions (Baron only)", bastions);
|
||||||
|
|
||||||
|
let nameEdited = !!a.name;
|
||||||
|
name.oninput = () => {nameEdited = true;};
|
||||||
|
function syncType() {
|
||||||
|
if (!nameEdited) name.value = type.value;
|
||||||
|
bastionsField.style.display = (type.value === "Baron") ? "" : "none";
|
||||||
|
desc.textContent = AGENT_DESC[type.value] || "";
|
||||||
|
}
|
||||||
|
type.onchange = syncType;
|
||||||
|
syncType();
|
||||||
|
|
||||||
|
card._get = () => {
|
||||||
|
const o = {name: name.value, ...AGENT_PRESETS[type.value]};
|
||||||
|
if (type.value === "Baron") o.bonus_trade_goods = +bastions.value;
|
||||||
|
const fc = parsePairs(forced.value, true);
|
||||||
|
if (Object.keys(fc).length) o.forced_city = fc;
|
||||||
|
const at = parseInts(avail.value);
|
||||||
|
if (at) o.available_turns = at;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
card.append(
|
||||||
|
field("Agent", type),
|
||||||
|
field("Effect", desc),
|
||||||
|
field("Forced city (turn:city, csv)", forced),
|
||||||
|
field("Avail turns (csv, blank=all)", avail),
|
||||||
|
bastionsField,
|
||||||
|
el("div", {class: "card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("agents").append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- objective terms ---
|
||||||
|
function addTerm(t = {}) {
|
||||||
|
const card = el("div", {class: "card"});
|
||||||
|
const res = selectEl(SCORE_KEYS, t.resource || "renown");
|
||||||
|
const scalar = num(t.scalar ?? 1, {step: "any"});
|
||||||
|
const turn = turnSelect(t.turn);
|
||||||
|
const isLog = el("input", {type: "checkbox"});
|
||||||
|
isLog.checked = !!t.log_mapping;
|
||||||
|
const toggle = () => card.classList.toggle("log-on", isLog.checked);
|
||||||
|
isLog.onchange = toggle;
|
||||||
|
const expr = el("textarea", {rows: 1, placeholder: "(x) => Math.log2(x + 1)"});
|
||||||
|
expr.innerText = "(x) => Math.log2(x + 1)";
|
||||||
|
if (t._expr) expr.value = t._expr;
|
||||||
|
card._get = () => {
|
||||||
|
const o = {resource: res.value, scalar: +scalar.value};
|
||||||
|
if (turn.value !== "") o.turn = +turn.value;
|
||||||
|
if (isLog.checked) o.log_mapping = buildLogTable(expr.value);
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
const resF = field("Resource", res); resF.classList.add("linear-only");
|
||||||
|
const turnF = field("Turn", turn); turnF.classList.add("linear-only");
|
||||||
|
const exprF = field("JS function of amount x", expr); exprF.classList.add("log-cell");
|
||||||
|
card.append(
|
||||||
|
resF,
|
||||||
|
field("Scalar", scalar),
|
||||||
|
turnF,
|
||||||
|
el("div", {class: "log-group"}, [field("Log", isLog), exprF]),
|
||||||
|
el("div", {class: "card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("terms").append(card);
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
function addTerms(terms = {}) {
|
||||||
|
Object.entries(terms).forEach(([resource, scalar]) =>
|
||||||
|
addTerm({resource, scalar}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eval the expression ONCE into a function, then call it over the amounts
|
||||||
|
// the lookup table needs (0..max_resource).
|
||||||
|
function buildLogTable(exprStr) {
|
||||||
|
let fn;
|
||||||
|
try {
|
||||||
|
fn = eval(exprStr);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("Could not eval log expression: " + e.message);
|
||||||
|
}
|
||||||
|
if (typeof fn !== "function")
|
||||||
|
throw new Error("Log expression must eval to a function, got " + typeof fn);
|
||||||
|
const max = +document.getElementById("max_resource").value || 0;
|
||||||
|
const table = [];
|
||||||
|
for (let x = 0; x <= max; x++) {
|
||||||
|
const v = Number(fn(x));
|
||||||
|
table.push(Number.isFinite(v) ? v : 0);
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resource constraints ---
|
||||||
|
function addConstraint(c = {}) {
|
||||||
|
const card = el("div", {class: "card"});
|
||||||
|
const res = selectEl(SCORE_KEYS, c.resource || "capital");
|
||||||
|
const op = selectEl([">=", "<=", "=="], c.op || ">=");
|
||||||
|
const value = num(c.value ?? 0);
|
||||||
|
const turn = turnSelect(c.turn);
|
||||||
|
card._get = () => {
|
||||||
|
const o = {resource: res.value, op: op.value, value: +value.value};
|
||||||
|
if (turn.value !== "") o.turn = +turn.value;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
card.append(
|
||||||
|
field("Resource", res),
|
||||||
|
field("Op", op),
|
||||||
|
field("Value", value),
|
||||||
|
field("Turn (blank=final)", turn),
|
||||||
|
el("div", {class: "card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("constraints").append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parsing helpers ---
|
||||||
|
function parseInts(s) {
|
||||||
|
const out = s.split(",").map(x => x.trim()).filter(Boolean).map(Number);
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
function parseStrs(s) {
|
||||||
|
const out = s.split(",").map(x => x.trim()).filter(Boolean);
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
function parsePairs(s, valueIsString = false) {
|
||||||
|
const o = {};
|
||||||
|
for (const part of s.split(",").map(x => x.trim()).filter(Boolean)) {
|
||||||
|
const [k, v] = part.split(":").map(x => x.trim());
|
||||||
|
o[+k] = valueIsString ? v : v;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(id) {
|
||||||
|
return [...document.getElementById(id).children].map(r => r._get());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProblem() {
|
||||||
|
const start = {};
|
||||||
|
for (const r of RESOURCES) {const v = +startInputs[r].value; if (v) start[r] = v;}
|
||||||
|
const tradeable_into = RESOURCES.filter(r => tradeInputs[r].checked);
|
||||||
|
return {
|
||||||
|
turns: +document.getElementById("turns").value,
|
||||||
|
extra_renown: +document.getElementById("extra_renown").value,
|
||||||
|
airships_launched: +document.getElementById("airships_launched").value,
|
||||||
|
max_resource: +document.getElementById("max_resource").value,
|
||||||
|
max_vat: +document.getElementById("max_vat").value,
|
||||||
|
start,
|
||||||
|
tradeable_into,
|
||||||
|
cities: collect("cities"),
|
||||||
|
agents: collect("agents"),
|
||||||
|
objective: {terms: collect("terms")},
|
||||||
|
resource_constraints: collect("constraints"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each solve produces its own collapsible <details> card; older ones are
|
||||||
|
// kept (collapsed) so previous solutions stay viewable. solutionCount labels
|
||||||
|
// them in request order.
|
||||||
|
let solutionCount = 0;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const errBox = document.getElementById("error");
|
||||||
|
const out = document.getElementById("output");
|
||||||
|
errBox.textContent = "";
|
||||||
|
// A placeholder card for this request, prepended so the newest is on top.
|
||||||
|
// It's replaced by the solution on success, or removed on error.
|
||||||
|
const pending = el("p", {class: "pending"}, "Solving…");
|
||||||
|
out.prepend(pending);
|
||||||
|
let problem, time;
|
||||||
|
try {
|
||||||
|
problem = buildProblem();
|
||||||
|
time = +document.getElementById("time").value;
|
||||||
|
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/solve", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({problem, max_time_seconds: time}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
|
||||||
|
renderSolution(data, pending);
|
||||||
|
} catch (e) {pending.remove(); errBox.textContent = e.message;}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSolution(s, placeholder) {
|
||||||
|
// Collapse any previously-shown solutions so the new one is the focus.
|
||||||
|
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
|
||||||
|
|
||||||
|
const n = ++solutionCount;
|
||||||
|
const details = el("details", {class: "solution", open: ""});
|
||||||
|
details.append(el("summary", {},
|
||||||
|
`Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`));
|
||||||
|
const out = details;
|
||||||
|
out.append(el("p", {
|
||||||
|
html:
|
||||||
|
`<b>Status:</b> ${s.status} <b>Objective:</b> ${s.objective_value ?? "—"} ` +
|
||||||
|
`<b>Final renown total:</b> ${s.final_renown_total}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fr = el("p", {});
|
||||||
|
fr.append(el("b", {}, "Final resources: "));
|
||||||
|
fr.append(document.createTextNode(
|
||||||
|
Object.entries(s.final_resources).map(([k, v]) => `${k}=${fmtNum(v)}`).join(", ") || "—"));
|
||||||
|
out.append(fr);
|
||||||
|
|
||||||
|
if (s.plan && s.plan.length) {
|
||||||
|
out.append(el("h3", {}, "Plan"));
|
||||||
|
out.append(gridTable(
|
||||||
|
["Turn", "City", "Action", "Detail", "Governor", "Overwork"],
|
||||||
|
s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor,
|
||||||
|
p.overwork ? "yes" : ""])));
|
||||||
|
} else {
|
||||||
|
out.append(el("p", {}, "(no actions / no feasible plan)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource amounts at the end of each turn.
|
||||||
|
if (s.resources_by_turn && s.resources_by_turn.length) {
|
||||||
|
const cols = RESOURCES.filter(r => s.resources_by_turn.some(row => row[r] !== undefined));
|
||||||
|
out.append(el("h3", {}, "Resources by turn"));
|
||||||
|
out.append(gridTable(
|
||||||
|
["Turn"].concat(cols),
|
||||||
|
s.resources_by_turn.map(row =>
|
||||||
|
[row.turn].concat(cols.map(r => fmtNum(row[r]))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.trade_conversions && s.trade_conversions.length) {
|
||||||
|
out.append(el("h3", {}, "Trade Goods conversions"));
|
||||||
|
out.append(gridTable(
|
||||||
|
["Turn", "Converted into", "Amount"],
|
||||||
|
s.trade_conversions.map(c => [c.turn, c.resource, fmtNum(c.amount)])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap the finished card in for this request's placeholder.
|
||||||
|
if (placeholder) placeholder.replaceWith(details);
|
||||||
|
else document.getElementById("output").prepend(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing ".0" so whole numbers read cleanly.
|
||||||
|
function fmtNum(v) {
|
||||||
|
if (typeof v !== "number") return String(v);
|
||||||
|
return Number.isInteger(v) ? String(v) : String(+v.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- seed with the example problem ---
|
||||||
|
addCity({name: "0", type: "hub", renown: 2});
|
||||||
|
addCity({name: "1", type: "foundry", renown: 2, vat_steel: 1, vat_brass: 1, vat_electrum: 1});
|
||||||
|
addCity({name: "2", type: "hub", renown: 2});
|
||||||
|
addCity({name: "3", type: "foundry", renown: 2});
|
||||||
|
addCity({name: "4", type: "monument", renown: 2});
|
||||||
|
addAgent({kind: "Planner"});
|
||||||
|
addTerms({
|
||||||
|
"renown": 0,
|
||||||
|
"luxuries": 1,
|
||||||
|
"steel": 2,
|
||||||
|
"brass": 1,
|
||||||
|
"electrum": 2
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
74
main.py
Normal file
74
main.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""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
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def _send(self, code, body, content_type="application/json"):
|
||||||
|
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)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path in ("/", "/index.html"):
|
||||||
|
self._send(200, INDEX_HTML, "text/html; charset=utf-8")
|
||||||
|
else:
|
||||||
|
self._send(404, json.dumps({"error": "not found"}))
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/solve":
|
||||||
|
self._send(404, json.dumps({"error": "not found"}))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
problem = problem_from_dict(payload.get("problem", {}))
|
||||||
|
max_time = float(payload.get("max_time_seconds", 30.0))
|
||||||
|
sol = solve(problem, max_time_seconds=max_time)
|
||||||
|
self._send(200, json.dumps(solution_to_dict(sol)))
|
||||||
|
except Exception as exc: # surface errors to the browser
|
||||||
|
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args): # quieter console
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
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]
|
[project]
|
||||||
name = "solve"
|
name = "dws-solve"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.1.3",
|
|
||||||
"ortools>=9.15.6755",
|
"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]]
|
[[package]]
|
||||||
name = "blinker"
|
name = "dws-solve"
|
||||||
version = "1.9.0"
|
version = "0.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { virtual = "." }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "click"
|
|
||||||
version = "8.4.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "ortools" },
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[package.metadata]
|
||||||
name = "colorama"
|
requires-dist = [{ name = "ortools", specifier = ">=9.15.6755" }]
|
||||||
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]]
|
[[package]]
|
||||||
name = "immutabledict"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.4.6"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|
@ -339,15 +215,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35c
|
||||||
wheels = [
|
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" },
|
{ 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