commit b9cdb3bb260841c8ba23b1f97188bcc01b993786 Author: Pagwin Date: Tue Jun 16 13:59:07 2026 -0400 initial hopefully correct model 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 0000000..12a893e Binary files /dev/null and b/__pycache__/solve.cpython-313.pyc differ 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" }, +]