From b9cdb3bb260841c8ba23b1f97188bcc01b993786 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Tue, 16 Jun 2026 13:59:07 -0400 Subject: [PATCH] initial hopefully correct model --- .python-version | 1 + .serena/.gitignore | 2 + .serena/project.yml | 133 ++++ README.md | 154 +++++ __pycache__/solve.cpython-313.pyc | Bin 0 -> 50181 bytes example.json | 17 + main.py | 6 + pyproject.toml | 9 + solve.py | 1041 +++++++++++++++++++++++++++++ uv.lock | 217 ++++++ 10 files changed, 1580 insertions(+) create mode 100644 .python-version create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 README.md create mode 100644 __pycache__/solve.cpython-313.pyc create mode 100644 example.json create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 solve.py create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..6c0a4cb --- /dev/null +++ b/.serena/project.yml @@ -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: [] diff --git a/README.md b/README.md new file mode 100644 index 0000000..990f826 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# dws-solve + +A planner optimizer for the **Days Without Strife** Faction *Planner* role, +built on Google OR-Tools (CP-SAT). It plans Industry Actions across the game's +Turns to maximize a weighted score over the end-game resources. + +## What it optimizes + +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, + +it finds the sequence of per-city Industry Actions (Collect / Renovate / +Upgrade / Launch Airship / Idle) that maximizes the objective. + +Objective is one of two forms over the resources +*Renown, Luxuries, Capital, Steel, Brass, Electrum, Trade Goods, Express tickets*: + +- **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). + +"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. + +## Usage + +### As a CLI (JSON in / JSON out) + +```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 +``` + +### 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. diff --git a/__pycache__/solve.cpython-313.pyc b/__pycache__/solve.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12a893e5b6fc007b84db4c4e226a774fbe3c7f33 GIT binary patch literal 50181 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 Ieb{FG4#q?f!2kdN literal 0 HcmV?d00001 diff --git a/example.json b/example.json new file mode 100644 index 0000000..639c2ed --- /dev/null +++ b/example.json @@ -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} + } +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..79a21a1 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from dws-solve!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..582fde3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "dws-solve" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "ortools>=9.15.6755", +] diff --git a/solve.py b/solve.py new file mode 100644 index 0000000..23ad0fd --- /dev/null +++ b/solve.py @@ -0,0 +1,1041 @@ +"""Days Without Strife - Planner optimizer. + +This module models the *Planner* side of Days Without Strife: a Faction's +Industry Actions over a number of Turns, and finds the plan that maximizes a +weighted score over the end-game resources. + +It uses Google OR-Tools (CP-SAT). + +What is modeled +--------------- +For each Turn each controlled City may take exactly one Industry Action: + + * COLLECT - gain Resources based on the City's type + * RENOVATE - change the City's type (not the central Metropolis) + * UPGRADE - spend Steel to install one of the City's Upgrades + * LAUNCH - spend 7 Steel to launch an Airship (+3 Renown) + * IDLE - do nothing + +City types and their Collection (most cost 1 Capital): + + Hub : [+2 Capital] OR [spend 1 Capital -> +2 Luxuries] + Foundry : 3 vats (Steel/Brass/Electrum). Collect one vat -> spend 1 + Capital, gain X (the vat's current level), empty that vat, + and +1 to the other two vats. + Monument : spend 1 Capital -> +2 Renown to this City + Metropolis : spend 1 Capital -> +2 Trade Goods and +1 Renown to this City + +Upgrades (Steel cost in brackets, each grants +1 City Renown unless noted): + + Infrastructure [0] : -1 to the cost of future Upgrades here (no Renown) + Harvester [2] : +1 to Collection yield + Fine Dining [2] : (Hub) +2 bonus Luxuries whenever Resources collected + Overflow Vats [2] : (Foundry) the two un-collected vats get +1 extra + Transit Auth. [2] : (Metropolis) +1 Express Ticket whenever collected + Propaganda [2] : (Monument) military only - no resource effect + Fortification [4] : +2 Renown (military defense otherwise) + +Governors / Overwork: + + The Planner Leader has *Overwork*: when appointed Governor of a City it + doubles that City's Collection that Turn and waives the Capital cost, but + the City cannot Collect the following Turn. The Planner can govern at most + one City per Turn. Generic governor Agents may also grant a free Upgrade + or bonus Trade Goods on Collect. + +Objective: + + For each scored resource n, either ``scalar_n * amount_n`` (linear) or + ``scalar_n * log_mapping[n][amount_n]`` (a caller-supplied lookup table). + "Renown" is scored as the total final Renown of controlled assets + (Cities + Agents + Airships), each capped at 1..9. + +Simplifications (documented): + * Trade Goods may be exchanged 1-for-1 into other resources only at the + end of the horizon (for scoring), via ``tradeable_into``. + * Buying Trade Goods with Electrum (2.5 each, "awful") is not modeled. + * Only resource-relevant mechanics are modeled; combat/diplomacy are not. +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass, field, asdict +from enum import Enum +from typing import Optional + +from ortools.sat.python import cp_model + + +# --------------------------------------------------------------------------- # +# Constants +# --------------------------------------------------------------------------- # + +# Stockpiled resources (Renown is handled separately - it is not stockpiled). +RESOURCES = [ + "capital", + "luxuries", + "steel", + "brass", + "electrum", + "trade_goods", + "express", +] + +# Integer scale applied to (possibly fractional) objective scalars / table +# values so the CP-SAT objective stays integral. +OBJ_SCALE = 1000 + +# Upper bound used to bound resource accumulators (and AddElement domains in +# log mode). Override via Problem.max_resource if your game runs hotter. +DEFAULT_MAX_RESOURCE = 300 +DEFAULT_MAX_VAT = 12 # foundry vats are uncapped in the rules; bounded here. +RENOWN_MIN, RENOWN_MAX = 1, 9 +AIRSHIP_COST_STEEL = 7 +AIRSHIP_RENOWN = 3 +AIRSHIP_MAX = 3 # each Faction can Launch at most 3 Airships total + + +class CityType(str, Enum): + HUB = "hub" + FOUNDRY = "foundry" + MONUMENT = "monument" + METROPOLIS = "metropolis" + + +class Action(str, Enum): + IDLE = "idle" + COLLECT = "collect" + RENOVATE = "renovate" + UPGRADE = "upgrade" + LAUNCH = "launch" + + +# Upgrade keys and their base Steel cost / Renown gain. +UPGRADES = { + "infrastructure": dict(cost=0, renown=0), + "harvester": dict(cost=2, renown=1), + "fine_dining": dict(cost=2, renown=1), # hub + "overflow_vats": dict(cost=2, renown=1), # foundry + "transit_authority": dict(cost=2, renown=1), # metropolis + "propaganda": dict(cost=2, renown=1), # monument + "fortification": dict(cost=4, renown=2), +} + +# Which type-specific "third" upgrade each city type may install. +TYPE_UPGRADE = { + CityType.HUB: "fine_dining", + CityType.FOUNDRY: "overflow_vats", + CityType.METROPOLIS: "transit_authority", + CityType.MONUMENT: "propaganda", +} + +# Reverse map: which city type a type-specific upgrade belongs to. +UPGRADE_OF_TYPE = {u: ct for ct, u in TYPE_UPGRADE.items()} + +# Upgrades that apply to any city type and persist permanently. The remaining +# upgrades are type-specific (the "third" upgrade) and only exist while the City +# is that type - renovating to a new type removes the old type's upgrade and +# unlocks the new one. +UNIVERSAL_UPGRADES = {"infrastructure", "harvester", "fortification"} + + +# --------------------------------------------------------------------------- # +# Input dataclasses +# --------------------------------------------------------------------------- # + +@dataclass +class City: + name: str + type: CityType + # Starting city renown. Cities start at 2, except the central Metropolis at + # 4 (rulebook p.18). Left as None => defaulted by type in __post_init__. + renown: Optional[int] = None + # Foundry vats: starting level of each vat resource. + vat_steel: int = 0 + vat_brass: int = 0 + vat_electrum: int = 0 + upgrades: list[str] = field(default_factory=list) # already-installed + available_turns: Optional[list[int]] = None # None => all turns + can_renovate: bool = True # Metropolis cannot renovate + # Hard constraint: force a specific action on a given turn (turn -> Action). + forced_action: dict[int, str] = field(default_factory=dict) + + def __post_init__(self): + if self.renown is None: + self.renown = 4 if self.type == CityType.METROPOLIS else 2 + + def is_available(self, t: int) -> bool: + return self.available_turns is None or t in self.available_turns + + +@dataclass +class Agent: + """A Faction Agent that can be appointed Governor. + + The Planner Leader (overwork=True) is the main lever. Other industrial + agents can be expressed with the generic effect flags. + """ + name: str + overwork: bool = False # Planner: double + waive cost + free_upgrade: bool = False # e.g. Brotherhood Builder + bonus_trade_goods: int = 0 # e.g. Baron: +N Trade Goods on collect + available_turns: Optional[list[int]] = None + # Hard constraint: must govern this city on this turn (turn -> city name). + forced_city: dict[int, str] = field(default_factory=dict) + + def is_available(self, t: int) -> bool: + return self.available_turns is None or t in self.available_turns + + # -- Named constructors for known governor Agents -------------------- # + + @classmethod + def planner(cls, name: str = "Planner", **kw) -> "Agent": + """Faction Planner Leader: Overwork (double collection, waive Capital + cost, lock next-Turn collect on the governed City).""" + return cls(name=name, overwork=True, **kw) + + @classmethod + def baron(cls, name: str = "Baron", bastions: int = 3, **kw) -> "Agent": + """Brotherhood Baron: as Governor, gains +1 Trade Good per Bastion on + the map during Collection. Defaults to 3 Bastions per request.""" + return cls(name=name, bonus_trade_goods=bastions, **kw) + + @classmethod + def builder(cls, name: str = "Builder", **kw) -> "Agent": + """Brotherhood Builder: as Governor, the City gains its type-specific + 3rd Upgrade for free (modeled as waiving the Steel cost of the Upgrade + installed that Turn).""" + return cls(name=name, free_upgrade=True, **kw) + + +@dataclass +class Objective: + # mode: "linear" -> score scalar*amount ; "log" -> scalar*log_mapping[amount] + mode: str = "linear" + # Per-resource weight. Key is a resource name or "renown". + scalars: dict[str, float] = field(default_factory=dict) + # Only used when mode == "log": resource/renown -> [value@0, value@1, ...]. + log_mapping: dict[str, list[float]] = field(default_factory=dict) + + +@dataclass +class Problem: + turns: int = 5 + cities: list[City] = field(default_factory=list) + agents: list[Agent] = field(default_factory=list) + start: dict[str, float] = field(default_factory=dict) # resource -> amount + objective: Objective = field(default_factory=Objective) + # Final Renown of assets that are not modeled Cities (other agents, + # already-launched airships, etc.) - added as a constant to the score. + extra_renown: int = 0 + # Airships this Faction has already launched (counts toward the max of 3). + airships_launched: int = 0 + # Trade Goods may be converted 1-for-1 into these resources for scoring. + tradeable_into: list[str] = field( + default_factory=lambda: ["capital", "luxuries", "steel", "brass", "electrum"] + ) + max_resource: int = DEFAULT_MAX_RESOURCE + max_vat: int = DEFAULT_MAX_VAT + + +# --------------------------------------------------------------------------- # +# Output dataclasses +# --------------------------------------------------------------------------- # + +@dataclass +class CityTurnPlan: + turn: int + city: str + action: str + detail: str = "" # e.g. collect choice, upgrade name, renovate target + governor: str = "" # agent appointed governor (if any) + overwork: bool = False + + +@dataclass +class Solution: + status: str + objective_value: float + final_resources: dict[str, float] + final_renown_total: int + plan: list[CityTurnPlan] = field(default_factory=list) + # Mid-game Trade Goods conversions: list of {turn, resource, amount}. + trade_conversions: list[dict] = field(default_factory=list) + + +# --------------------------------------------------------------------------- # +# Model construction +# --------------------------------------------------------------------------- # + +class _Builder: + def __init__(self, problem: Problem): + self.p = problem + self.m = cp_model.CpModel() + self.T = problem.turns + self.MAXR = problem.max_resource + self.MAXV = problem.max_vat + # resource accumulators: res[name][t] = amount at END of turn t. + # res[name][-1] handled via start; we index 0..T-1 and seed turn 0. + self.res: dict[str, list[cp_model.IntVar]] = {} + # resource production delta per (resource, turn). + self.delta: dict[tuple[str, int], list] = {} + # per (city_index, turn) action booleans + self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {} + # governor assignment: gov[(agent_idx, city_idx, turn)] bool + self.gov: dict[tuple[int, int, int], cp_model.IntVar] = {} + # overwork[(city_idx, turn)] bool (planner governs & overworks this city) + self.overwork: dict[tuple[int, int], cp_model.IntVar] = {} + # final renown per city + self.city_final_renown: list[cp_model.IntVar] = [] + # airship launch booleans (each adds AIRSHIP_RENOWN to asset renown) + self.launches: list[cp_model.IntVar] = [] + # bookkeeping for solution extraction + self._collect_detail: dict[tuple[int, int], dict] = {} + self._upgrade_choice: dict[tuple[int, int], dict] = {} + self._renovate_choice: dict[tuple[int, int], dict] = {} + + # -- helpers ----------------------------------------------------------- # + + def _mul_bool(self, expr, b: cp_model.IntVar, ub: int): + """Return an IntVar equal to expr * b where b is boolean, 0<=expr<=ub.""" + y = self.m.NewIntVar(0, ub, "") + self.m.Add(y <= expr) + self.m.Add(y <= ub * b) + self.m.Add(y >= expr - ub * (1 - b)) + # y >= 0 by domain + return y + + def _add_delta(self, resource: str, t: int, expr): + self.delta.setdefault((resource, t), []).append(expr) + + # -- main build -------------------------------------------------------- # + + def build(self): + self._build_actions_and_governors() + self._build_city_dynamics() + self._build_trade_conversion() + self._build_resource_balance() + self._build_objective() + return self.m + + def _build_actions_and_governors(self): + m = self.m + cities, agents = self.p.cities, self.p.agents + + # Action selection: exactly one action per city per turn. + for ci, city in enumerate(cities): + for t in range(self.T): + avail = city.is_available(t) + vs = {} + for a in Action: + # Metropolis cannot renovate; renovate also needs can_renovate. + if a == Action.RENOVATE and ( + not city.can_renovate or city.type == CityType.METROPOLIS + ): + vs[a] = m.NewConstant(0) + continue + vs[a] = m.NewBoolVar(f"act_c{ci}_t{t}_{a.value}") + self.act[(ci, t, a)] = vs[a] + # store metropolis/no-renovate as constant + for a in Action: + self.act[(ci, t, a)] = vs[a] + + if not avail: + # City unavailable: forced idle. + for a in Action: + if a != Action.IDLE: + m.Add(vs[a] == 0) + m.Add(vs[Action.IDLE] == 1) + else: + m.Add(sum(vs[a] for a in Action) == 1) + + # Forced action constraint. + if t in city.forced_action and avail: + forced = Action(city.forced_action[t]) + m.Add(vs[forced] == 1) + + # Governor assignment. + for ai, agent in enumerate(agents): + for t in range(self.T): + if not agent.is_available(t): + continue + gv = [] + for ci in range(len(cities)): + g = m.NewBoolVar(f"gov_a{ai}_c{ci}_t{t}") + self.gov[(ai, ci, t)] = g + gv.append(g) + # Each agent governs at most one city per turn. + m.Add(sum(gv) <= 1) + # Forced governor placement. + if t in agent.forced_city: + target = agent.forced_city[t] + ci = self._city_index(target) + m.Add(self.gov[(ai, ci, t)] == 1) + + # Each city has at most one governor per turn. + for ci in range(len(cities)): + for t in range(self.T): + govs = [ + self.gov[(ai, ci, t)] + for ai in range(len(agents)) + if (ai, ci, t) in self.gov + ] + if govs: + m.Add(sum(govs) <= 1) + + # Overwork bool: planner (overwork agent) governs this city. + overwork_agents = [ai for ai, a in enumerate(agents) if a.overwork] + for ci in range(len(cities)): + for t in range(self.T): + contrib = [ + self.gov[(ai, ci, t)] + for ai in overwork_agents + if (ai, ci, t) in self.gov + ] + ow = m.NewBoolVar(f"overwork_c{ci}_t{t}") + if contrib: + m.Add(ow == sum(contrib)) # at most one planner total + else: + m.Add(ow == 0) + self.overwork[(ci, t)] = ow + + # Only one overworking placement at a time is already implied by + # the agent's "<=1 city" constraint. + + def _city_index(self, name: str) -> int: + for i, c in enumerate(self.p.cities): + if c.name == name: + return i + raise KeyError(f"Unknown city: {name}") + + def _build_city_dynamics(self): + for ci, city in enumerate(self.p.cities): + self._build_one_city(ci, city) + + def _build_one_city(self, ci: int, city: City): + m = self.m + T = self.T + + # --- City type over time (renovation) ------------------------------ + # Built first because the installability and persistence of the + # type-specific (3rd) upgrade depend on the City's *current* type. + # We model an active-type indicator per turn; default = starting type. + # Renovation target is chosen among the 3 other resource-types. + # To keep collection math tractable we only allow collection math for + # the *current* type; renovating just switches future behavior. + type_active = {ct: [] for ct in CityType} + renov_to = {} # (ct, t) -> bool : renovate to ct on turn t + for t in range(T): + for ct in CityType: + type_active[ct].append(m.NewBoolVar(f"type_{ci}_{ct.value}_t{t}")) + m.Add(sum(type_active[ct][t] for ct in CityType) == 1) + + renovate_allowed = ( + city.can_renovate and city.type != CityType.METROPOLIS + ) + for t in range(T): + renov_action = self.act[(ci, t, Action.RENOVATE)] + choices = [] + for ct in CityType: + if ct == CityType.METROPOLIS: + continue # cannot renovate *into* metropolis + r = m.NewBoolVar(f"renov_{ci}_{ct.value}_t{t}") + renov_to[(ct, t)] = r + choices.append(r) + if renovate_allowed and choices: + m.Add(sum(choices) == renov_action) + else: + m.Add(renov_action == 0) + for ct in CityType: + if (ct, t) in renov_to: + m.Add(renov_to[(ct, t)] == 0) + self._renovate_choice[(ci, t)] = { + ct.value: renov_to[(ct, t)] for ct in CityType if (ct, t) in renov_to + } + + # Type transition: type@t = renov target if renovated, else type@t-1. + for ct in CityType: + prev = ( + type_active[ct][t - 1] + if t > 0 + else m.NewConstant(1 if ct == city.type else 0) + ) + r = renov_to.get((ct, t)) + if r is not None: + # active(ct,t) == renovated_to_ct OR (prev AND not renovating) + # Linearize: active = prev - prev*renov_action + r + # since renovating sets exactly one r and clears others. + not_renov_keep = self._mul_bool(prev, 1 - renov_action, 1) + m.Add(type_active[ct][t] == not_renov_keep + r) + else: + # metropolis target impossible; keep only if not renovating. + not_renov_keep = self._mul_bool(prev, 1 - renov_action, 1) + m.Add(type_active[ct][t] == not_renov_keep) + + # --- Upgrade state ------------------------------------------------- + # installed[u][t] = upgrade u is installed by END of turn t. + installed = {u: [] for u in UPGRADES} + # Universal upgrades apply to any type; type-specific (3rd) upgrades + # only exist while the City is that type. A City can install a + # type-specific upgrade for every type it can ever become (its starting + # type, plus the renovate targets if renovation is allowed). + reachable_types = {city.type} + if renovate_allowed: + reachable_types |= {CityType.HUB, CityType.FOUNDRY, CityType.MONUMENT} + installable = set(UNIVERSAL_UPGRADES) | { + TYPE_UPGRADE[ct] for ct in reachable_types + } + # installs driven by the City's UPGRADE Industry Action (cost Steel) + act_install = {} # (u, t) -> bool + # type-specific upgrade granted for free by a Builder Governor, applied + # independently of whichever Industry Action the City takes. + builder_free = {} # (u, t) -> bool + # any new install this turn (action or Builder); used for Renown/detail. + new_install = {} # (u, t) -> bool | expr + + for u in UPGRADES: + for t in range(T): + st = m.NewBoolVar(f"inst_{ci}_{u}_t{t}") + installed[u].append(st) + + for u in UPGRADES: + preinstalled = u in city.upgrades + # The type a type-specific upgrade requires (None for universal). + ct_req = UPGRADE_OF_TYPE.get(u) + for t in range(T): + if u not in installable: + # type-specific upgrade for a type this City can't become. + m.Add(installed[u][t] == 0) + continue + # "currently the right type" gate (1 for universal upgrades). + type_gate = type_active[ct_req][t] if ct_req is not None else None + prev = installed[u][t - 1] if t > 0 else m.NewConstant( + 1 if preinstalled else 0 + ) + # A type-specific upgrade is lost if the City renovates away. + keep = prev if type_gate is None else self._mul_bool(prev, type_gate, 1) + + ai = m.NewBoolVar(f"actinst_{ci}_{u}_t{t}") + act_install[(u, t)] = ai + if type_gate is not None: + # can only action-install while currently this type. + m.Add(ai <= type_gate) + ni = ai + if ct_req is not None: + # Builder Governor: installs the City's current type-specific + # upgrade for free this turn, regardless of its action. + bf_src = self._free_upgrade_bool(ci, t) + if bf_src is not None: + bf = m.NewBoolVar(f"builderfree_{ci}_{u}_t{t}") + # bf = bf_src AND currently this type AND not already kept + m.Add(bf <= bf_src) + m.Add(bf <= type_gate) + m.Add(bf <= 1 - keep) + m.Add(bf >= bf_src + type_gate + (1 - keep) - 2) + builder_free[(u, t)] = bf + ni = ai + bf + # installed = keep OR new ; can't (re)install if already kept. + m.Add(installed[u][t] == keep + ni) + m.Add(keep + ni <= 1) + new_install[(u, t)] = ni + + # At most one *action* upgrade per turn, and only if action==UPGRADE. + # (Builder-granted installs are separate and do not consume the action.) + for t in range(T): + ups_this_turn = [ + act_install[(u, t)] for u in UPGRADES if (u, t) in act_install + ] + up_action = self.act[(ci, t, Action.UPGRADE)] + if ups_this_turn: + m.Add(sum(ups_this_turn) == up_action) + else: + m.Add(up_action == 0) + self._upgrade_choice[(ci, t)] = { + u: new_install[(u, t)] for u in UPGRADES if (u, t) in new_install + } + + # --- Collection ---------------------------------------------------- + self._build_collection(ci, city, installed, type_active) + + # --- Foundry vats -------------------------------------------------- + self._build_vats(ci, city, installed, type_active) + + # --- Steel spend for upgrades & airships --------------------------- + for t in range(T): + # Steel cost of the upgrade installed this turn, reduced by an + # already-present Infrastructure (installed by previous turn). + infra_prev = installed["infrastructure"][t - 1] if t > 0 else m.NewConstant( + 1 if "infrastructure" in city.upgrades else 0 + ) + cost_terms = [] + for u in UPGRADES: + # Only action-driven upgrades cost Steel; Builder-granted + # installs are free. + if (u, t) not in act_install: + continue + base = UPGRADES[u]["cost"] + if base == 0: + continue + ni = act_install[(u, t)] + # effective cost = max(0, base - infra_prev) when installing u; + # reduced if infra present: base*ni - infra_prev*ni + discounted = self._mul_bool(infra_prev, ni, 1) + cost_terms.append(base * ni - discounted) + if cost_terms: + self._add_delta("steel", t, -sum(cost_terms)) + + # Airship launch: -7 steel, +3 asset renown (added to renown total). + launch = self.act[(ci, t, Action.LAUNCH)] + self._add_delta("steel", t, -AIRSHIP_COST_STEEL * launch) + self.launches.append(launch) + + # --- Overwork "no collect next turn" lock -------------------------- + for t in range(T - 1): + # if overworked at t, cannot collect at t+1 + m.Add(self.act[(ci, t + 1, Action.COLLECT)] == 0).OnlyEnforceIf( + self.overwork[(ci, t)] + ) + # Overwork requires a collection this turn (otherwise pointless, and a + # governor that overworks implies the city collects). + for t in range(T): + m.Add(self.act[(ci, t, Action.COLLECT)] >= self.overwork[(ci, t)]) + + # --- Launches need available steel handled by balance; renown ------- + # --- Final renown of the city -------------------------------------- + self._build_city_renown(ci, city, installed, type_active) + + def _free_upgrade_bool(self, ci: int, t: int): + agents = self.p.agents + contrib = [] + for ai, a in enumerate(agents): + if a.free_upgrade and (ai, ci, t) in self.gov: + contrib.append(self.gov[(ai, ci, t)]) + if not contrib: + return None + b = self.m.NewBoolVar(f"freeup_c{ci}_t{t}") + self.m.Add(b == sum(contrib)) + return b + + def _bonus_tg_expr(self, ci: int, t: int): + agents = self.p.agents + terms = [] + for ai, a in enumerate(agents): + if a.bonus_trade_goods and (ai, ci, t) in self.gov: + terms.append(a.bonus_trade_goods * self.gov[(ai, ci, t)]) + return sum(terms) if terms else None + + def _build_collection(self, ci, city, installed, type_active): + """Add per-turn resource deltas from Collection, for each possible type.""" + m = self.m + T = self.T + for t in range(T): + collect = self.act[(ci, t, Action.COLLECT)] + ow = self.overwork[(ci, t)] + harv = installed["harvester"][t] + # multiplier: yield doubled when overworked. We compute base gain + # then add the same again when ow=1. + # Capital cost of collection is 1 unless overworked (waived); + # applied per-type below. + + # ---- HUB (capital option) : +2 capital, no capital cost -------- + hub_active = type_active[CityType.HUB][t] + hub_collect = self._mul_bool(collect, hub_active, 1) + # Hub sub-choice: capital vs luxuries + hub_lux_choice = m.NewBoolVar(f"hublux_{ci}_t{t}") + m.Add(hub_lux_choice <= hub_collect) + hub_cap_choice = m.NewIntVar(0, 1, f"hubcap_{ci}_t{t}") + m.Add(hub_cap_choice == hub_collect - hub_lux_choice) + fine = installed["fine_dining"][t] + + # capital option: +2 (+harvester) capital, doubled if overwork + cap_base = 2 * hub_cap_choice + self._mul_bool(harv, hub_cap_choice, 1) + cap_total = cap_base + self._mul_bool(cap_base, ow, self.MAXR) + self._add_delta("capital", t, cap_total) + # capital option fine dining bonus luxuries + fd_cap = self._mul_bool(fine, hub_cap_choice, 1) + fd_cap_tot = 2 * fd_cap + self._mul_bool(2 * fd_cap, ow, self.MAXR) + self._add_delta("luxuries", t, fd_cap_tot) + + # luxuries option: spend 1 capital -> +2 (+harvester) luxuries + lux_base = 2 * hub_lux_choice + self._mul_bool(harv, hub_lux_choice, 1) + # fine dining adds +2 lux too + fd_lux = self._mul_bool(fine, hub_lux_choice, 1) + lux_base = lux_base + 2 * fd_lux + lux_total = lux_base + self._mul_bool(lux_base, ow, self.MAXR) + self._add_delta("luxuries", t, lux_total) + # capital cost for the luxuries option (waived under overwork) + lux_cost = self._mul_bool(hub_lux_choice, 1 - ow, 1) + self._add_delta("capital", t, -lux_cost) + + # ---- MONUMENT : spend 1 capital -> +2 city renown (renown later) + mon_active = type_active[CityType.MONUMENT][t] + mon_collect = self._mul_bool(collect, mon_active, 1) + mon_cost = self._mul_bool(mon_collect, 1 - ow, 1) + self._add_delta("capital", t, -mon_cost) + # renown handled in _build_city_renown via mon_collect & ow & harv + + # ---- METROPOLIS : spend 1 capital -> +2 TG, +1 renown ---------- + met_active = type_active[CityType.METROPOLIS][t] + met_collect = self._mul_bool(collect, met_active, 1) + transit = installed["transit_authority"][t] + met_cost = self._mul_bool(met_collect, 1 - ow, 1) + self._add_delta("capital", t, -met_cost) + tg_base = 2 * met_collect + self._mul_bool(harv, met_collect, 1) + tg_total = tg_base + self._mul_bool(tg_base, ow, self.MAXR) + self._add_delta("trade_goods", t, tg_total) + # transit authority: +1 express ticket on collect + exp_base = self._mul_bool(transit, met_collect, 1) + exp_total = exp_base + self._mul_bool(exp_base, ow, self.MAXR) + self._add_delta("express", t, exp_total) + + # ---- bonus trade goods from governor agents (e.g. Baron) ------- + # Baron grants +N Trade Goods on Collection (N = bonus per Bastion). + # Applied whenever the city Collects (any type) while governed. + btg = self._bonus_tg_expr(ci, t) + if btg is not None: + self._add_delta("trade_goods", t, self._gate_expr(btg, collect)) + + # store detail + self._collect_detail[(ci, t)] = dict( + hub_lux=hub_lux_choice, + ) + # NOTE: Foundry collection handled in _build_vats. + + def _gate_expr(self, expr, b): + """Return expr if b else 0, for a small non-negative linear expr.""" + ub = self.MAXR + y = self.m.NewIntVar(0, ub, "") + self.m.Add(y <= expr) + self.m.Add(y <= ub * b) + self.m.Add(y >= expr - ub * (1 - b)) + return y + + def _build_vats(self, ci, city, installed, type_active): + """Foundry vats and their collection. Vats grow only on collection.""" + m = self.m + T = self.T + vat_res = {"steel": city.vat_steel, "brass": city.vat_brass, + "electrum": city.vat_electrum} + # vat[res][t] = level at START of turn t. + vat = {r: [] for r in vat_res} + for r, start in vat_res.items(): + for t in range(T): + v = m.NewIntVar(0, self.MAXV, f"vat_{ci}_{r}_t{t}") + vat[r].append(v) + m.Add(vat[r][0] == start) + + self._vat_choice = getattr(self, "_vat_choice", {}) + for t in range(T): + collect = self.act[(ci, t, Action.COLLECT)] + ow = self.overwork[(ci, t)] + harv = installed["harvester"][t] + overflow = installed["overflow_vats"][t] + found_active = type_active[CityType.FOUNDRY][t] + found_collect = self._mul_bool(collect, found_active, 1) + # choose which vat to collect (at most one, only if found_collect) + pick = {} + for r in vat_res: + pick[r] = m.NewBoolVar(f"vatpick_{ci}_{r}_t{t}") + m.Add(pick[r] <= found_collect) + m.Add(sum(pick[r] for r in vat_res) == found_collect) + self._vat_choice[(ci, t)] = pick + + # capital cost (waived under overwork) + f_cost = self._mul_bool(found_collect, 1 - ow, 1) + self._add_delta("capital", t, -f_cost) + + for r in vat_res: + # yield if picked = vat level (+harvester), doubled if overwork + picked_level = self._gate_expr(vat[r][t], pick[r]) + gain_base = picked_level + self._mul_bool(harv, pick[r], 1) + gain_total = gain_base + self._mul_bool(gain_base, ow, self.MAXR) + self._add_delta(r, t, gain_total) + + # transition to next turn's vat levels + if t + 1 < T: + for r in vat_res: + # if picked: -> 0 + # else if some other vat picked (found_collect & not pick[r]): + # +1 (+1 more if overflow) + # else (no foundry collect): unchanged + other_collect = m.NewBoolVar(f"vatother_{ci}_{r}_t{t}") + # other_collect = found_collect AND not pick[r] + m.Add(other_collect <= found_collect) + m.Add(other_collect <= 1 - pick[r]) + m.Add(other_collect >= found_collect - pick[r]) + inc = other_collect + self._mul_bool(overflow, other_collect, 1) + keep = self._gate_expr(vat[r][t], 1 - found_collect) + # next = keep (if no foundry collect) + (vat[r][t]+inc if other) + other_keep = self._gate_expr(vat[r][t], other_collect) + nxt = keep + other_keep + inc + # cap at MAXV + m.Add(vat[r][t + 1] <= self.MAXV) + m.Add(vat[r][t + 1] == nxt) + + def _build_city_renown(self, ci, city, installed, type_active): + m = self.m + T = self.T + # renown accumulates from: starting renown + upgrade renown gains + # + Monument collects (+2 ea, *2 if overwork) + Metropolis collects (+1) + # + airship launches do NOT add to city renown (they add asset renown). + gains = [] + # upgrade renown: sum over upgrades newly installed of their renown. + # installed[u][T-1] minus preinstalled tells if newly installed by end. + for u, info in UPGRADES.items(): + if info["renown"] == 0: + continue + end = installed[u][T - 1] + pre = 1 if u in city.upgrades else 0 + gains.append(info["renown"] * (end - pre)) + for t in range(T): + collect = self.act[(ci, t, Action.COLLECT)] + mon_active = type_active[CityType.MONUMENT][t] + met_active = type_active[CityType.METROPOLIS][t] + mon_collect = self._mul_bool(collect, mon_active, 1) + met_collect = self._mul_bool(collect, met_active, 1) + # Renown is not a Resource, so Overwork (which doubles the Resources + # Collected) does NOT double these Renown gains. + gains.append(2 * mon_collect) # Monument: +2 Renown on collect + gains.append(1 * met_collect) # Metropolis: +1 Renown on collect + + raw = m.NewIntVar(0, 1000, f"rawrenown_{ci}") + m.Add(raw == city.renown + sum(gains)) + # capped to [1, 9] + capped = m.NewIntVar(RENOWN_MIN, RENOWN_MAX, f"renown_{ci}") + m.AddMinEquality(capped, [raw, m.NewConstant(RENOWN_MAX)]) + # raw >= 1 always (starts >=2), so min with 9 is enough; ensure >=1 + self.city_final_renown.append(capped) + + def _build_trade_conversion(self): + """Mid-game Trade Goods exchange: each Turn, convert Trade Goods 1-for-1 + into any resource in ``tradeable_into``. The converted resource becomes + available that same Turn (and onward). Over-conversion is prevented by + the non-negative Trade Goods balance enforced each Turn.""" + m = self.m + self.trade_conv: dict[tuple[str, int], cp_model.IntVar] = {} + # Express Tickets cannot be acquired via Trade Goods conversion. + targets = [ + r for r in self.p.tradeable_into if r in RESOURCES and r != "express" + ] + for t in range(self.T): + for r in targets: + c = m.NewIntVar(0, self.MAXR, f"tgconv_{r}_t{t}") + self.trade_conv[(r, t)] = c + self._add_delta(r, t, c) # +1 target resource + self._add_delta("trade_goods", t, -c) # -1 Trade Good + + def _build_resource_balance(self): + m = self.m + T = self.T + for r in RESOURCES: + start = int(round(self.p.start.get(r, 0))) + self.res[r] = [] + for t in range(T): + v = m.NewIntVar(0, self.MAXR, f"res_{r}_t{t}") + self.res[r].append(v) + prev = self.res[r][t - 1] if t > 0 else start + deltas = self.delta.get((r, t), []) + m.Add(v == prev + sum(deltas)) + # resources must never go negative (enforced by domain >= 0). + + def _build_objective(self): + m = self.m + T = self.T + obj = self.p.objective + terms = [] + + # Each Faction can Launch at most 3 Airships total (incl. any already + # launched before the planning horizon). + if self.launches: + m.Add(sum(self.launches) <= AIRSHIP_MAX - self.p.airships_launched) + + # Renown total = sum of city final renown + extra. + renown_total = m.NewIntVar(0, 100000, "renown_total") + launch_renown = AIRSHIP_RENOWN * sum(self.launches) if self.launches else 0 + m.Add(renown_total + == sum(self.city_final_renown) + self.p.extra_renown + launch_renown) + self.renown_total = renown_total + + # Trade Goods conversion is handled mid-game (see _build_trade_conversion), + # so end-of-game amounts are simply the resource balances at the last Turn. + final_amt = {r: self.res[r][T - 1] for r in RESOURCES} + self.final_amt = final_amt + + def scaled(x: float) -> int: + return int(round(x * OBJ_SCALE)) + + if obj.mode == "linear": + for r in RESOURCES: + s = obj.scalars.get(r, 0.0) + if s: + terms.append(scaled(s) * final_amt[r]) + s = obj.scalars.get("renown", 0.0) + if s: + terms.append(scaled(s) * renown_total) + elif obj.mode == "log": + for key, amt_var in ( + [(r, final_amt[r]) for r in RESOURCES] + + [("renown", renown_total)] + ): + s = obj.scalars.get(key, 0.0) + if not s: + continue + table = obj.log_mapping.get(key) + if table is None: + raise ValueError(f"log mode requires log_mapping[{key!r}]") + # value = table[min(amt, len-1)]; scale table values to ints. + vals = [scaled(v) for v in table] + idx = m.NewIntVar(0, len(table) - 1, f"idx_{key}") + m.AddMinEquality(idx, [amt_var, m.NewConstant(len(table) - 1)]) + val = m.NewIntVar(min(vals), max(vals), f"logval_{key}") + m.AddElement(idx, vals, val) + terms.append(int(round(s)) * val) + else: + raise ValueError(f"Unknown objective mode: {obj.mode}") + + m.Maximize(sum(terms)) + + +# --------------------------------------------------------------------------- # +# Solve entry point +# --------------------------------------------------------------------------- # + +def solve(problem: Problem, max_time_seconds: float = 30.0, + workers: int = 8) -> Solution: + builder = _Builder(problem) + model = builder.build() + solver = cp_model.CpSolver() + solver.parameters.max_time_in_seconds = max_time_seconds + solver.parameters.num_search_workers = workers + status = solver.Solve(model) + status_name = solver.StatusName(status) + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + return Solution( + status=status_name, objective_value=float("nan"), + final_resources={}, final_renown_total=0, plan=[], + ) + + return _extract(problem, builder, solver, status_name) + + +def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solution: + T = b.T + plan: list[CityTurnPlan] = [] + for ci, city in enumerate(problem.cities): + for t in range(T): + chosen = None + for a in Action: + v = b.act[(ci, t, a)] + if solver.Value(v) == 1: + chosen = a + break + if chosen is None or chosen == Action.IDLE: + continue + detail = "" + governor = "" + overwork = bool(solver.Value(b.overwork[(ci, t)])) + # governor name + for ai, agent in enumerate(problem.agents): + if (ai, ci, t) in b.gov and solver.Value(b.gov[(ai, ci, t)]) == 1: + governor = agent.name + if chosen == Action.COLLECT: + # determine collect detail + pick = b._vat_choice.get((ci, t)) + if pick and any(solver.Value(pick[r]) for r in pick): + r = next(r for r in pick if solver.Value(pick[r])) + detail = f"foundry vat: {r}" + else: + cd = b._collect_detail.get((ci, t), {}) + if "hub_lux" in cd and solver.Value(cd["hub_lux"]) == 1: + detail = "hub: +2 luxuries" + else: + detail = "collect" + elif chosen == Action.UPGRADE: + uc = b._upgrade_choice.get((ci, t), {}) + for u, var in uc.items(): + if solver.Value(var) == 1: + detail = f"upgrade: {u}" + elif chosen == Action.RENOVATE: + rc = b._renovate_choice.get((ci, t), {}) + for ct, var in rc.items(): + if solver.Value(var) == 1: + detail = f"renovate -> {ct}" + elif chosen == Action.LAUNCH: + detail = "launch airship" + plan.append(CityTurnPlan( + turn=t, city=city.name, action=chosen.value, + detail=detail, governor=governor, overwork=overwork, + )) + + final_resources = { + r: float(solver.Value(b.final_amt[r])) for r in RESOURCES + } + conversions = [] + for (r, t), var in sorted(b.trade_conv.items(), key=lambda kv: (kv[0][1], kv[0][0])): + amt = solver.Value(var) + if amt: + conversions.append({"turn": t, "resource": r, "amount": amt}) + return Solution( + status=status_name, + objective_value=solver.ObjectiveValue() / OBJ_SCALE, + final_resources=final_resources, + final_renown_total=int(solver.Value(b.renown_total)), + plan=sorted(plan, key=lambda p: (p.turn, p.city)), + trade_conversions=conversions, + ) + + +# --------------------------------------------------------------------------- # +# JSON interface +# --------------------------------------------------------------------------- # + +def problem_from_dict(d: dict) -> Problem: + cities = [] + for c in d.get("cities", []): + c = dict(c) + c["type"] = CityType(c["type"]) + if "forced_action" in c: + c["forced_action"] = {int(k): v for k, v in c["forced_action"].items()} + cities.append(City(**c)) + agents = [] + for a in d.get("agents", []): + a = dict(a) + if "forced_city" in a: + a["forced_city"] = {int(k): v for k, v in a["forced_city"].items()} + agents.append(Agent(**a)) + obj = Objective(**d.get("objective", {})) + kwargs = {k: v for k, v in d.items() + if k not in ("cities", "agents", "objective")} + return Problem(cities=cities, agents=agents, objective=obj, **kwargs) + + +def solution_to_dict(s: Solution) -> dict: + out = asdict(s) + return out + + +def main(argv: list[str]) -> int: + import argparse + ap = argparse.ArgumentParser(description="Days Without Strife planner optimizer") + ap.add_argument("input", nargs="?", help="input JSON file (default stdin)") + ap.add_argument("-o", "--output", help="output JSON file (default stdout)") + ap.add_argument("--time", type=float, default=30.0, help="solver time limit (s)") + args = ap.parse_args(argv) + + raw = open(args.input).read() if args.input else sys.stdin.read() + problem = problem_from_dict(json.loads(raw)) + sol = solve(problem, max_time_seconds=args.time) + out = json.dumps(solution_to_dict(sol), indent=2) + if args.output: + with open(args.output, "w") as f: + f.write(out) + else: + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cda674a --- /dev/null +++ b/uv.lock @@ -0,0 +1,217 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "dws-solve" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "ortools" }, +] + +[package.metadata] +requires-dist = [{ name = "ortools", specifier = ">=9.15.6755" }] + +[[package]] +name = "immutabledict" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/e6/718471048fea0366c3e3d1df3acfd914ca66d571cdffcf6d37bbcd725708/immutabledict-4.3.1.tar.gz", hash = "sha256:f844a669106cfdc73f47b1a9da003782fb17dc955a54c80972e0d93d1c63c514", size = 7806, upload-time = "2026-02-15T10:32:34.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ce/f9018bf69ae91b273b6391a095e7c93fa5e1617f25b6ba81ad4b20c9df10/immutabledict-4.3.1-py3-none-any.whl", hash = "sha256:c9facdc0ff30fdb8e35bd16532026cac472a549e182c94fa201b51b25e4bf7bf", size = 5000, upload-time = "2026-02-15T10:32:33.672Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + +[[package]] +name = "ortools" +version = "9.15.6755" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "immutabledict" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/53/e21c54ff10002cc2e2b9748012ffc324ec32ea4acdcc85e190a920ab2766/ortools-9.15.6755-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:27a10474e62c9dceed37cfa0e4845c5ffaf792138ebf5b61483771b96f1290b6", size = 23927705, upload-time = "2026-01-14T15:39:07.29Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e6/f7019048ffdf41f8a1bff6815b2203cf7b9117ba9e26bf46c4585421d1c4/ortools-9.15.6755-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:076565b803c85c4f87863e0616f537dd37f99c03e6f092e4068404f7b425d2b0", size = 21914246, upload-time = "2026-01-14T15:39:10.584Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ad/aaacd340918b03e22c42f6ae4a9c72aac09810b4b398e99a7eeee58d9c42/ortools-9.15.6755-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b85bd20259b146abce5e0721ce1bfd8fd273efc904216aa3be178c31b6d34057", size = 27646600, upload-time = "2026-01-14T15:38:04.79Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/28d5efb832190b6edfccc5a703e88e64779c1eda34a42ea96d03307236c0/ortools-9.15.6755-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebd5aea00374e3aad7a78de59058aca5e871a26a3c385cd0860ef1d685d03c9a", size = 29838741, upload-time = "2026-01-14T15:38:07.945Z" }, + { url = "https://files.pythonhosted.org/packages/be/22/ab894b6f846b4b1a89795c1ba966834e56cac394c4cf2b72433909739982/ortools-9.15.6755-cp313-cp313-win_amd64.whl", hash = "sha256:caac1d48b967adb877da2abcaf82c28f0f908a7cc208a6a1bbe01bc69590816c", size = 23908100, upload-time = "2026-01-14T15:39:48.398Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/ada4146ae491d7798c6eb045d93135158c0b66030853c7cd9607768dda59/ortools-9.15.6755-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82b4a8e6e4f9380b453ab5fa4382ea7ee91e628f9b8be89d9ad760b33fca3323", size = 27681510, upload-time = "2026-01-14T15:38:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/e6/239e96912fc8c4e0e917e72ec413983bc042cd9a0b20c3c6a7e43fc3002b/ortools-9.15.6755-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d1f2fb2088e8953ccb902e68ffd06032cce0c7dcf7268b6135f3b6c553ca52b", size = 29850935, upload-time = "2026-01-14T15:38:14.595Z" }, + { url = "https://files.pythonhosted.org/packages/53/ef/53a172ad12cf0d762b9a5af681b1f13f1b4105b38bf65c2b383d530ed97f/ortools-9.15.6755-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:acdf06a167933307608e7eba23a9490255933504df44c8de5f62c48656c29688", size = 23916963, upload-time = "2026-01-14T15:39:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/13/54/ed73ec00369fb6d6c71049d62e4b7c87c918b61f86ddd55a11c20ada395e/ortools-9.15.6755-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a0677270b0cd317a6b8dae42514264eaf5da5756c5bc7215eeea409424577df", size = 21923649, upload-time = "2026-01-14T15:39:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e0/ac57dd43eaadd73748bb542b30912e16c7dbf3a75f393f69efb8a1a2f032/ortools-9.15.6755-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:899b92afe3f775ab5867b9a8aa2850f81f2d95232db9b4ceec3456d69e6b8528", size = 27657273, upload-time = "2026-01-14T15:38:18.375Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e0/11144feb4ddadc491dc9d833d3a2080e6556245f912bebe2c0c7e174f2a1/ortools-9.15.6755-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7181183cdcafe2b0d83ca5505b65048c7953dc7b5ad479361dded607964cc1b3", size = 29843939, upload-time = "2026-01-14T15:38:21.457Z" }, + { url = "https://files.pythonhosted.org/packages/96/97/771515ba3a05da3903b7da55a190d9f88f36a08c4bf848852e0ea4e3a731/ortools-9.15.6755-cp314-cp314-win_amd64.whl", hash = "sha256:afabb869e5fabeb704bd8147b22bf8139dee042e55fabd0d447a996428009e0c", size = 24673633, upload-time = "2026-01-14T15:39:51.212Z" }, + { url = "https://files.pythonhosted.org/packages/46/99/0932d6d7d6ad326adf68f4ce9063ef07db7e9859859dddbcd200102aedff/ortools-9.15.6755-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9d07cddca201e25e2e219006a9d6cda10c7e9ee2c712c50d19d508f9ed8a888", size = 27682088, upload-time = "2026-01-14T15:38:25.174Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4d/bd75961e2c82db69bb41dd2c4a82131ca580e997485be2d5f59f8d26f31e/ortools-9.15.6755-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:990838ad66a052e72a50e69da500878710e3420e91717fe88bf3071995caba9e", size = 29851493, upload-time = "2026-01-14T15:38:28.168Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +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" }, +]