diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f7550b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +.venv diff --git a/__pycache__/solve.cpython-313.pyc b/__pycache__/solve.cpython-313.pyc deleted file mode 100644 index 12a893e..0000000 Binary files a/__pycache__/solve.cpython-313.pyc and /dev/null differ diff --git a/agents.txt b/agents.txt new file mode 100644 index 0000000..b35fc44 --- /dev/null +++ b/agents.txt @@ -0,0 +1,441 @@ +Celebrity +A Diplomatic Follower who has placed their services up for Bid +Starts with 3 Renown +Abilities +• Star Power +• SpyTech Slot +• SpyTech Slot + +It took me a few decades in this place to realize that I was hearing the same few songs on loop for all that time. Upon noticing this I went to check the museums and found them full of familiar pieces as well. While there is new music and art being made, the rate seems incredibly slow. +I've never actually met a formal "Artist" in this place. Perhaps there is one, just one, out in the world somewhere. And perhaps that singular title is squandered on a hack. +For the rest of the country, you have people like our Celebrity, who sings because she is good at it, not because she seems to enjoy it + +___ +Princess +A Diplomatic Follower who has placed their services up for Bid +Cannot be Arrested +Abilities +• Privilege +• SpyTech Slot +• SpyTech Slot + +I've heard the Princess never actually cared for the throne, and that she seeks to run away and live as a commoner +Then again, she can't eat breakfast without stirring up three rumors to the contrary, so perhaps it's best to give her privacy + +___ +Liaison +A Diplomatic Follower who has placed their services up for Bid +Reduces all Telegram Dash costs by 1, to a minimum of 1 +Abilities +• Communication +• SpyTech Slot +• SpyTech Slot + +It would seem that with the advancements in telegram routing, modern factions have decided they no longer have any need for Adjutants. However, I believe they may have lost something in removing a crucial human element from their communication lines. +This absence has left a role for more specialized communicators to step in, and some factions seem willing to take them on. Even if it means hiring known con men like the Liaison. + +___ +Influencer +A Diplomatic Follower who has placed their services up for Bid +Gains +1 Renown at the start of each Turn +Abilities +• Next Big Thing +• SpyTech Slot +• SpyTech Slot + +I thought her a kind but naive soul when I first saw her, but I've long since learned to challenge that impulse. Each mask hides a complex inner world, and those who trade in entertainment are by necessity fine maskmakers. +But after a long conversation, my initial appraisal was mostly correct. She is certainly kind, but I found her naivety unusually self-inflicted. She faces the unknown not without wit, but instead without guile, casting into the darkness nothing but a wish and a smile on the grounds that these forces are more powerful than the hand of fate or the fire of knowledge. +She is certain this is enough, and it pains me to not be able to agree with her + +___ +Advocate +A Diplomatic Follower who has placed their services up for Bid +Can Commend a Faction or Wildcard, as the Speaker +Abilities +• Commend +• SpyTech Slot +• SpyTech Slot + +I must admit I had a poor first impression of the Advocate, a distrust owed to my upbringing. I was unfair to her, but she was stalwart, kind, and assertive in response. In short time, she convinced me I was wrong, but in truth, it was her misfortunes that let me see her humanity. She rejected the title of her youth, and now she hardly sleeps as she works to make up for lost time. When I saw her struggle, I started listening, and I am ashamed it took me so long. +After that, we bonded over a shared interest in writing. She still sends me letters to this day, sometimes in verse. They are one of the few practical joys I have to look forward to. + +___ +Veteran +A Military Follower who has placed their services up for Bid +Starts with 3 Renown +Abilities +• Old Soldier +• Army Orders + +I met him many years ago, when he was the Adjutant for the Silver Rangers. Seems he's managed to survive much longer than his old faction. +I hope to see him again. No one is quite as good at telling war stories, probably because there isn't a soul alive with a sharper mind. + +___ +Warden +A Military Follower who has placed their services up for Bid +Can Surveil Followers, as the Marshal +Abilities +• Surveil +• Army Orders + +It seems a title can follow one a bit past its literal meaning. Where I am from, a Warden is someone who keeps prisoners locked up, not someone who hunts them down. Also, they don't typically lead armies. +Apparently, his actions during a prison break turned manhunt turned border skirmish caused him to rise rapidly through the ranks. It seems to have happened so fast his title never had time to update properly. +It remains odd to me that the people here just take this in stride + +___ +Commando +A Military Follower who has placed their services up for Bid +Moves first in Combat, as the Commander +Abilities +• First Strike +• Army Orders + +A dangerous man, and a real outlier +It only takes a brief conversation with him, which he will probably start if you look unoccupied in a bar, to determine that he really should be of higher status. He is a leader, in all but rank. +But he has some quality, some detail that prevents him from rising. What's especially strange is that he seems to not notice this limitation on his station. When he talks, crowds turn to listen, but then he just leaves and everyone forgets about him. Bizarre. + +___ +Maverick +A Military Follower who has placed their services up for Bid +Gets +1 Support in Combat +Abilities +• Militia Tactics +• Army Orders + +The Maverick is a man of many talents and secrets. His story is a far more fascinating tale than mine, and it's not one I can do justice to here. + +___ +Firebrand +A Military Follower who has placed their services up for Bid +Gains double Renown +Abilities +• Fire of Knowledge +• Army Orders + +Few of my notes and memories remain unburned by the unnatural power that the Firebrand wields + +___ +Capitalist +An Industrial Follower who has placed their services up for Bid +As Governor, increases Capital Collection by +2 +Abilities +• Infinite Growth + +Currency is a necessary part of a society, as an abstract concept and then logically as a physical object +Currency is how we track and conceptualize value, but certainly it must be someone's job to keep track of it, even just materially +Is the fact that those who make capital itself their business usually end up rich a byproduct of how highly society values it, or just a perk of the job +Perhaps you wouldn't be surprised that the Baker has a taste for bread + +___ +Vinter +An Industrial Follower who has placed their services up for Bid +As Governor, increases Luxury Collection by +2 +Abilities +• Fine Harvest + +On a promise to my mother, and then later my church, I had not indulged in any alcohol prior to my arrival here. I kept those promises for a few years after, until I found that sharing a drink with someone was by far the best way to learn their story. +The Vinter, for example, rarely indulges in her own craft. When she does, the only topic she will speak on is this Poet she knew in her youth, which displeases her current wife. +The way she speaks of this Poet, however, makes me question a few of my other vows... + +___ +Metallurgist +An Industrial Follower who has placed their services up for Bid +As Governor of a Foundry, grants the effects of the Overflow Vats Upgrade. Does not stack with actual Upgrade. +Abilities +• Metallurgy + +I knew him, years ago. He had been hired by the Auroral Synastry to work on their steel production. I was paid 0.0027 electrum to assist him, but there was little I could do beyond work the bellows. He was a savant, but would complain constantly. +The work continued until one of his outbursts caused an accident that drowned him in molten slag. The site was condemned, the Synastry never got their steel, and they were all but wiped off the map in mere days. +Years later, I find the new Metallurgist, but it's the same man, and he doesn't appear to have aged. His eyes met mine, and I know I saw a glimmer of recognition. Whatever he may be, he is not what he seems. + +___ +Foreman +An Industrial Follower who has placed their services up for Bid +As Governor, Renovating their City does not prevent other Industry Actions +Abilities +• Restructure + +I've never met someone so ecstatic to do their job +If this world is cruel to others for how it forces people into their roles, then it is kind to the Foreman, who enjoys every minute of it + +___ +Artificer +An Industrial Follower who has placed their services up for Bid +As Governor, Collects an extra Trade Good +Abilities +• Wondrous Craft + +Possessed of a brilliant but scattered mind, one that others are willing to use for their advantage +With proper guidance, her inventions could help this world, but as of now they are simply trinkets to sell to fuel the war machine +I wish I still possessed enough strength to provide assistance. I must continue with the knowledge that there are other actors out there with the strength of will to help in my stead. Perhaps, if she can find herself, this Artificer will stand among them. + +___ +Fence +A Diplomatic Follower who has placed their services up for Bid +When hired, offers a one time bonus of 1 Express Ticket and 2 Trade Goods +Abilities +• Customs Acquisitions +• SpyTech Slot +• SpyTech Slot + +I'll need more time to collect my thoughts on this topic... + +___ +Connoisseur +A Diplomatic Follower who has placed their services up for Bid +When hired, offers a one time bonus of 5 Luxuries +Abilities +• Flight of Fancy +• SpyTech Slot +• SpyTech Slot + +Is it even possible to call oneself a Connoisseur if one has not ever indulged to excess? And once that line is found, how does one decide how much of their remaining time should be spent on the sensible side of that line? Expertise in understanding a given amenity naturally carries the risk of overindulgence, but can there be any hope for understanding without risk? +These were the justifications given to me by this famous Connoisseur, whose taste and appetite for excess are rivaled only by her passion and knowledge of her field + +___ +Prophet +A Diplomatic Follower who has placed their services up for Bid +Can Condemn another Faction, causing their Commander to lose 1 Renown +Abilities +• Condemn +• SpyTech Slot +• SpyTech Slot + +A Prophet of doom in general, and seemingly not of any faith in particular +As they have regularly called out the obvious evils of the world, this Prophet has developed something of a cult following, but seems to pay them no mind. I hear they have also started soliciting factions for access to certain amenities, promising their voice in exchange. +Whatever doom they speak of, it seems to not be that pressing of a concern +Still, there is a familiar strangeness about them. I think we are lucky their concerns are so far off. + +___ +Intercessor +A Diplomatic Follower who has placed their services up for Bid +Gains +2 Renown when their Faction forms an Alliance with a Faction, with a 1 Turn Cooldown +Abilities +• Curious Bargain +• SpyTech Slot +• SpyTech Slot + +This one is not as they seem. I know them from the previous war, and in my research into the mystic and unknown I have seen their face many times in paintings and photographic records going back centuries, always coinciding with disasters or other unusual events. +In times past they were a purveyor of strange powers, however recently they have abstained from granting boons in favor of mundane bargains. I tracked them down and asked why they hold back for this conflict, and in response I received a smile and this simple reply: +"Maybe next time" + +___ +Cosmopolitan +A Diplomatic Follower who has placed their services up for Bid +When appointed Governor, gains +1 Renown and invites all Factions and Wildcards to the Forum +While he is Governor, no one can be uninvited from the Forum +Abilities +• Party Host +• SpyTech Slot +• SpyTech Slot + +A private man, though also a very popular one paradoxically. It seems he makes quite the impression. +As I understand, he is very devoted to his wife and fears losing her above all else. She is fond of parties, and so he takes every opportunity to host as many as possible, even if he himself avoids the limelight. I haven't met his wife, but the Tinker has and says "He has nothing to worry about, so long as he stays on his best behaviour." +It seems he is well aware of this pressure + +___ +Sentinel +A Military Follower who has placed their services up for Bid +Starts with a Scanner +Abilities +• Well Equipped +• Army Orders + +I'll need more time to collect my thoughts on this topic... + +___ +Loyalist +A Military Follower who has placed their services up for Bid +When hired, their new Faction's Commander gains +2 Renown +Abilities +• Right Hand +• Army Orders + +I'll need more time to collect my thoughts on this topic... + +___ +Admiral +A Military Follower who has placed their services up for Bid +Arrives aboard The Hellenic, an Airship with damaged Engines which he will not leave +Abilities +• Flagship +• Army Orders + +The first and only captain of the Monarchy's flagship, and a man running behind on his quest for revenge +He only flew the Hellenic during the parade circuit and was loathe to spend any time on land, but he was convinced by his partner and son to take a day of leave before the ship's first voyage across the sea. However, it was during this fateful day that a chance encounter with the Hurricane escalated into an exchange of cannonfire that left the Hellenic on a collision course with a mountain while the Hurricane was reportedly unscathed. +The Admiral has spent the intervening decades seething about his lost chance for glory. As his family is no longer an issue, perhaps one day he'll get another shot. + +___ +Ascendant +A Military Follower who has placed their services up for Bid +When this Agent's Army Captures a City, he gains +1 Renown +Abilities +• Rise to Glory +• Army Orders + +The sole remaining survivor of a doomed underground expedition. I had initially gone to speak to him about the "true faith below" he claimed to have found, but he was unable to carry a conversation. +He spoke, in scattered fragments, of a forgotten empire, a lost faith, and the secret of platinum, all buried below. He kept trying to steer the conversation to his new militaristic ideals, and his plans to rise to glory. +I'm led to believe he was a very different man before, one of the expedition scientists. I wonder, what could he have found down there that scarred him? However, I know any faction with the power to help him would find him more useful in this state than if his mind were clear. + +___ +Courier +An Industrial Follower who has placed their services up for Bid +When first appointed Governor, offers a one time bonus of 3 Capital, Steel, and Brass +Abilities +• Supply Cache + +Normally, one would expect a Courier who has delivered their package to gracefully exit and move on, but this one has made overstaying their welcome a dignified art + +___ +Provisioner +An Industrial Follower who has placed their services up for Bid +When appointed Governor of a City with a Base, their Faction gains 1.5 Electrum +Abilities +• Credit Line + +I'll need more time to collect my thoughts on this topic... + +___ +Prodigy +An Industrial Follower who has placed their services up for Bid +As Governor, when Upgrading or Launching an Airship, refund up to 2 Steel afterwards +Abilities +• Steel Efficiency + +She has a talent like no other, with apologies to her former mentor the Tinker. She's also studied with the Wharfmaster, the Miller, and at least one other, but it's safe to say she's surpassed them all. There is no mechanical problem she cannot solve twice as well and with half the materials, all while being totally unlike any existing approach. +What must it be like to be so brilliant? I can walk the world knowing that there are many others who can solve the problems I cannot, leaving me with no reason to concern myself with them. I think she might not share that same privilege. +Is it empowering, to feel so capable? Or horrifying to know that you are the only one with that capability? + +___ +Industrialist +An Industrial Follower who has placed their services up for Bid +When appointed Governor, their City and all adjacent Cities gain the Infrastructure Upgrade +Abilities +• Network + +An old quote from my homeland says that people are either charming or tedious +I am unsure if this is true, but if it is, then I would add that you should make sure not to spend all of your time around charming people +A charming person can show you a wonderful evening, but there is great meaning to be found in the slower, more meditative, and perhaps more tedious parts of life + +___ +Ambassador +A Diplomatic Follower who has placed their services up for Bid +Gains +2 Renown for each Faction Alliance their Faction has at the end of the game +Abilities +• Treaty +• SpyTech Slot +• SpyTech Slot + +I wonder what the appointed Ambassadors that are away on their diplomatic missions will do when the Monarchy finally gives out. Perhaps they can still find work among the bickering ideologies back home, as whichever Faction takes control will still have to contend with the remnants of their opponents in one way or another. +I don't suppose they will perform this undignified service for cheap though + +___ +Scoundrel +A Diplomatic Follower who has placed their services up for Bid +Worth 1 Renown for each Luxury spent to Bid for him +Abilities +• Notorious +• SpyTech Slot +• SpyTech Slot + +There comes a certain critical mass of notoriety where simply being well known is an asset worth investing in, a fact that many brands are built upon +Whatever duplicitous or malcontented deed earned this Scoundrel their reputation is by this point irrelevant, as there are many that will support anyone they can recognize, regardless of context +Though, if you are still curious, I would seek out Adelaide de Voce for the full story... + +___ +Usurper +A Military Follower who has placed their services up for Bid +Gains +3 Renown if his Faction DOES NOT control Kingsland at the end of the game +Abilities +• Behind the Throne +• Army Orders + +In a large and complex multi-sided war, it makes a certain degree of sense to invest in the possibility of losing, considering its high likelihood. It may even be preferable to wait to strike until the primary conflict is over and the winner has relaxed their defenses. +My old friend the Guerilla was fond of this "lose the battle but win the war by stabbing them in their sleep afterward" approach, and would pass it on to his followers. Some of them, like the proudly titled Usurper, still wear this idea, but others do not agree. +He died before realizing his goals, so I am unsure I can agree with this approach either. But since his ideals have outlasted him, perhaps it is too early to say. + +___ +Economist +An Industrial Follower who has placed their services up for Bid +Gain 1 Renown for every 10 Capital Collected and spent on Collection +Abilities +• Velocity of Money + +As with many who deal with money, she is very wealthy +Her background is in mathematics, with a specialty in the velocity of money. She very kindly spent a great length explaining her theories on how currency is only useful in the moment it changes hands, and would chart out the velocity of the economy, its acceleration and jerk, and her analysis of key moments of financial inflection. Just as she was getting to a topic that seemed important, she suddenly stopped, and said she had to cut the lecture short. I suspect in her fervor she nearly revealed some great insight about economics, the kind of idea that could make anyone rich. +Naturally, she kept that to herself + +___ +Futurist +An Industrial Follower who has placed their services up for Bid +All of his Faction's Forts and Airships gain +1 Renown +Abilities +• Tomorrow + +I'll need more time to collect my thoughts on this topic... + +___ +Neurologist +An Industrial Follower who has placed their services up for Bid +All of his Faction's Agents with SpyTech gain +1 Renown +Abilities +• Mind Games + +An intelligent man who often stews in a quiet rage that he never found the resources to do the work he wanted +When I audited a few classes at Balebriar University, I spent several weeks working with him in his clinic. His research into advanced applications of LungBrass were incredible, if troubling. He said he always wanted to do more, to push the boundaries, but could only find funding through universities or companies that would insist on claiming all the credit for his developments. +And when given the choice to continue his work for the benefit of others, or stop his research, he chose to quit, and in doing so deprived the world of his inventions altogether. Not that I believe this is his fault. + +___ +Baron +One of the Brotherhood's starting Followers +As Governor, Collecting generates +1 Trade Good per Bastion on the map +Abilities +• Barter +Like all Brotherhood Followers, his services can be loaned out to other Factions and Wildcards on Bonds through Trade + +___ +Barrister +One of the Brotherhood's starting Followers +Can take control of Neutral Cities like an Aristocrat but is not blocked by Aristocrats +When working for a Faction, also blocks enemy Aristocrats +Neither effect works on Kingsland +Abilities +• Break Bylaws +Like all Brotherhood Followers, his services can be loaned out to other Factions and Wildcards on Bonds through Trade + +___ +Brigadier +One of the Brotherhood's starting Followers +Can issue Army Orders, and his Army gets +1 Support per Bastion on the map +Abilities +• Berserk +• Army Orders +Like all Brotherhood Followers, his services can be loaned out to other Factions and Wildcards on Bonds through Trade + +___ +Barbarian +One of the Brotherhood's starting Followers +Can issue Army Orders, and their Army is counted as having 3 additional Soldiers per point of Renown they have +Abilities +• Berserk +• Army Orders +Like all Brotherhood Followers, their services can be loaned out to other Factions and Wildcards on Bonds through Trade + +___ +Builder +One of the Brotherhood's starting Followers +When appointed Governor of a City, gives that City its type-specific Upgrade +Abilities +• Build Better +Like all Brotherhood Followers, his services can be loaned out to other Factions and Wildcards on Bonds through Trade + +___ +Betrayer +One of the Brotherhood's starting Followers +Can issue Army Orders and has a Tranq Dart and Thieves Tools +Abilities +• Tranq Dart +• Army Orders +• Thieves Tools +Like all Brotherhood Followers, his services can be loaned out to other Factions and Wildcards on Bonds through Trade + diff --git a/main.py b/main.py index 79a21a1..3b1d52c 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,625 @@ +"""Web UI for the Days Without Strife planner (see solve.py). + +A dependency-free (stdlib only) HTTP server that exposes every input the +solver accepts: turns, starting resources, cities, agents, scoring terms +(linear or log), resource constraints and the misc Problem knobs. + +For log-scored terms the user supplies a JavaScript expression that evals into +a single-argument function (e.g. ``(x) => Math.log2(x)``). The browser evals it +once, then calls it over the amounts it needs (0..max_resource) to build the +``log_mapping`` lookup table, which is sent to the server as a plain array. +""" + +from __future__ import annotations + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from solve import problem_from_dict, solve, solution_to_dict + + +INDEX_HTML = r""" + + + + +Days Without Strife - Planner + + + +

Days Without Strife — Planner Optimizer

+ +
+ Game +
+ + + + + + +
+
+ +
+ Starting resources +
+
+ +
+ Tradeable into (Trade Goods convert 1-for-1 for scoring) +
+
+ +
+ Cities +

The name is the city's unique identifier — it labels the + output plan and is what an agent's "forced city" refers to. Upgrades are the + ones already installed at the start; the available choices follow the city's type.

+
+ +
+ +
+ Agents +

Pick a known agent; its governor behaviour is implicit and + shown in the Effect column. The name identifies it and is what a + forced city refers back to. All effects apply while the agent is Governor of a + city (at most one city per turn).

+
+ +
+ +
+ Objective — scoring terms +

Each term scores a resource (or renown) at the end of a + turn (blank turn = final turn). For a log term, write a JS expression that + evals to a one-argument function, e.g. (x) => Math.log2(x + 1). + It is called over amounts 0..max_resource in the browser to build the + lookup table.

+
+ +
+ +
+ Resource constraints +
+ +
+ +

+ +

+ +
+
+ + + + +""" + + +class Handler(BaseHTTPRequestHandler): + def _send(self, code, body, content_type="application/json"): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path in ("/", "/index.html"): + self._send(200, INDEX_HTML, "text/html; charset=utf-8") + else: + self._send(404, json.dumps({"error": "not found"})) + + def do_POST(self): + if self.path != "/solve": + self._send(404, json.dumps({"error": "not found"})) + return + try: + length = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(length) or b"{}") + problem = problem_from_dict(payload.get("problem", {})) + max_time = float(payload.get("max_time_seconds", 30.0)) + sol = solve(problem, max_time_seconds=max_time) + self._send(200, json.dumps(solution_to_dict(sol))) + except Exception as exc: # surface errors to the browser + self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) + + def log_message(self, fmt, *args): # quieter console + pass + + def main(): - print("Hello from dws-solve!") + host, port = "127.0.0.1", 8000 + server = ThreadingHTTPServer((host, port), Handler) + print(f"Days Without Strife planner UI: http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nshutting down") + server.shutdown() if __name__ == "__main__": diff --git a/solve.py b/solve.py index 23ad0fd..64cd88c 100644 --- a/solve.py +++ b/solve.py @@ -43,6 +43,14 @@ Governors / Overwork: one City per Turn. Generic governor Agents may also grant a free Upgrade or bonus Trade Goods on Collect. + Other modeled industrial Governor Agents (see Agent named constructors): + Baron (+Trade Goods/Bastion), Builder (free type-specific Upgrade), + Capitalist (+2 Capital), Vinter (+2 Luxuries), Artificer (+1 Trade Good), + Metallurgist (Overflow Vats on a Foundry), Industrialist (free + Infrastructure), Foreman (Renovate without spending the Action), Prodigy + (refund <=2 Steel on Upgrade/Launch), Provisioner (+1.5 Electrum per + governing Turn), Courier (one-time +3 Capital/Steel/Brass). + Objective: For each scored resource n, either ``scalar_n * amount_n`` (linear) or @@ -181,6 +189,18 @@ class Agent: 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 + # --- additional industrial Governor effects (see named constructors) --- + bonus_capital: int = 0 # Capitalist: +N Capital on Collect + bonus_luxuries: int = 0 # Vinter: +N Luxuries on Collect + grants_overflow_vats: bool = False # Metallurgist: Overflow Vats on a Foundry + grants_infrastructure: bool = False # Industrialist: free Infrastructure Upgrade + free_renovate: bool = False # Foreman: Renovate without spending the Action + steel_refund: int = 0 # Prodigy: refund up to N Steel on Upgrade/Launch + # Provisioner: +0.5*N Electrum each Turn this Agent governs a City (tracked in + # half-units so the integer model can represent the 1.5 = 3 half-unit gain). + governor_electrum_half: int = 0 + # Courier: one-time {resource: amount} bonus the first Turn this Agent governs. + onetime_governor_bonus: dict[str, int] = field(default_factory=dict) 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) @@ -209,16 +229,123 @@ class Agent: installed that Turn).""" return cls(name=name, free_upgrade=True, **kw) + @classmethod + def capitalist(cls, name: str = "Capitalist", **kw) -> "Agent": + """Capitalist: as Governor, increases Capital Collection by +2.""" + return cls(name=name, bonus_capital=2, **kw) + + @classmethod + def vinter(cls, name: str = "Vinter", **kw) -> "Agent": + """Vinter: as Governor, increases Luxury Collection by +2.""" + return cls(name=name, bonus_luxuries=2, **kw) + + @classmethod + def artificer(cls, name: str = "Artificer", **kw) -> "Agent": + """Artificer: as Governor, Collects an extra Trade Good.""" + return cls(name=name, bonus_trade_goods=1, **kw) + + @classmethod + def metallurgist(cls, name: str = "Metallurgist", **kw) -> "Agent": + """Metallurgist: as Governor of a Foundry, grants the effects of the + Overflow Vats Upgrade (does not stack with the actual Upgrade).""" + return cls(name=name, grants_overflow_vats=True, **kw) + + @classmethod + def industrialist(cls, name: str = "Industrialist", **kw) -> "Agent": + """Industrialist: when appointed Governor, their City gains the + Infrastructure Upgrade for free (the adjacent-City grant is not + modeled, as the map layout is outside this resource model).""" + return cls(name=name, grants_infrastructure=True, **kw) + + @classmethod + def foreman(cls, name: str = "Foreman", **kw) -> "Agent": + """Foreman: as Governor, Renovating their City does not prevent other + Industry Actions (the City may Renovate and still take a normal + Action the same Turn).""" + return cls(name=name, free_renovate=True, **kw) + + @classmethod + def prodigy(cls, name: str = "Prodigy", **kw) -> "Agent": + """Prodigy: as Governor, when Upgrading or Launching an Airship, refund + up to 2 Steel afterwards.""" + return cls(name=name, steel_refund=2, **kw) + + @classmethod + def provisioner(cls, name: str = "Provisioner", **kw) -> "Agent": + """Provisioner: each Turn this Agent governs a City, their Faction gains + 1.5 Electrum (modeled unconditionally - the "City with a Base" + requirement is not modeled).""" + return cls(name=name, governor_electrum_half=3, **kw) + + @classmethod + def courier(cls, name: str = "Courier", **kw) -> "Agent": + """Courier: when first appointed Governor, offers a one-time bonus of + 3 Capital, 3 Steel and 3 Brass.""" + return cls( + name=name, + onetime_governor_bonus={"capital": 3, "steel": 3, "brass": 3}, + **kw, + ) + + +@dataclass +class ScoreTerm: + """A single additive scoring term over a resource (or "renown"). + + The objective is the sum of all ScoreTerms. Each term scores the amount of + ``resource`` at the END of ``turn``: + + * linear (log_mapping is None) : ``scalar * amount`` + * log (log_mapping given) : ``scalar * log_mapping[min(amount, n-1)]`` + + ``turn`` is 0-indexed (same convention as forced_action); ``None`` means the + final Turn of the horizon. This lets e.g. steel on Turn 2 and steel on Turn 5 + carry different weights, and either can use a log mapping. + + ``resource`` is a stockpiled resource name or "renown". Because per-Turn + Renown is not tracked, a "renown" term must target the final Turn + (``turn is None``). + """ + resource: str + scalar: float = 1.0 + turn: Optional[int] = None + log_mapping: Optional[list[float]] = None + @dataclass class Objective: - # mode: "linear" -> score scalar*amount ; "log" -> scalar*log_mapping[amount] + """Scoring objective, expressed as a list of additive ScoreTerms. + + For backward compatibility, the legacy ``mode``/``scalars``/``log_mapping`` + fields are still accepted and are lowered into final-Turn ScoreTerms: + + * mode == "linear" : each ``scalars[key]`` -> linear term on final Turn. + * mode == "log" : each ``scalars[key]`` -> log term on final Turn, + using ``log_mapping[key]`` as the table. + """ + terms: list[ScoreTerm] = field(default_factory=list) + # --- legacy convenience fields (lowered into final-Turn terms) --------- 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) + def all_terms(self) -> list[ScoreTerm]: + """Return the explicit terms plus the lowered legacy scalars.""" + out = list(self.terms) + for key, s in self.scalars.items(): + if not s: + continue + if self.mode == "linear": + out.append(ScoreTerm(resource=key, scalar=s)) + elif self.mode == "log": + table = self.log_mapping.get(key) + if table is None: + raise ValueError(f"log mode requires log_mapping[{key!r}]") + out.append(ScoreTerm(resource=key, scalar=s, log_mapping=table)) + else: + raise ValueError(f"Unknown objective mode: {self.mode}") + return out + @dataclass class Problem: @@ -238,6 +365,10 @@ class Problem: ) max_resource: int = DEFAULT_MAX_RESOURCE max_vat: int = DEFAULT_MAX_VAT + # Hard constraints on a resource's amount at the END of a specific Turn. + # Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}. + # Turns are 0-indexed (None or omitted => final Turn). + resource_constraints: list[dict] = field(default_factory=list) # --------------------------------------------------------------------------- # @@ -263,6 +394,8 @@ class Solution: 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) + # Resource amounts at the END of each turn: list of {turn, : amount}. + resources_by_turn: list[dict] = field(default_factory=list) # --------------------------------------------------------------------------- # @@ -295,6 +428,8 @@ class _Builder: self._collect_detail: dict[tuple[int, int], dict] = {} self._upgrade_choice: dict[tuple[int, int], dict] = {} self._renovate_choice: dict[tuple[int, int], dict] = {} + # Foreman: extra Renovate that does not consume the City's Action. + self._foreman_renov: dict[tuple[int, int], cp_model.IntVar] = {} # -- helpers ----------------------------------------------------------- # @@ -316,7 +451,11 @@ class _Builder: self._build_actions_and_governors() self._build_city_dynamics() self._build_trade_conversion() + self._build_onetime_governor_bonuses() self._build_resource_balance() + self._build_provisioner_electrum() + self._build_renown_total() + self._build_resource_constraints() self._build_objective() return self.m @@ -437,6 +576,23 @@ class _Builder: ) for t in range(T): renov_action = self.act[(ci, t, Action.RENOVATE)] + # Foreman Governor: the City may Renovate *without* spending its + # Industry Action. ``foreman_renov`` is an extra Renovate that can + # co-occur with a non-Renovate primary Action. + foreman_src = self._gov_attr_bool( + ci, t, lambda a: a.free_renovate, "foreman") if ( + renovate_allowed and city.is_available(t)) else None + if foreman_src is not None: + foreman_renov = m.NewBoolVar(f"foremanrenov_{ci}_t{t}") + m.Add(foreman_renov <= foreman_src) + # Can't double up: the primary Action is already a Renovate. + m.Add(renov_action + foreman_renov <= 1) + else: + foreman_renov = m.NewConstant(0) + self._foreman_renov[(ci, t)] = foreman_renov + # Combined "did the City Renovate this Turn" indicator (0/1). + renov_total = renov_action + foreman_renov + choices = [] for ct in CityType: if ct == CityType.METROPOLIS: @@ -445,7 +601,7 @@ class _Builder: renov_to[(ct, t)] = r choices.append(r) if renovate_allowed and choices: - m.Add(sum(choices) == renov_action) + m.Add(sum(choices) == renov_total) else: m.Add(renov_action == 0) for ct in CityType: @@ -465,13 +621,13 @@ class _Builder: 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 + # Linearize: active = prev - prev*renov_total + r # since renovating sets exactly one r and clears others. - not_renov_keep = self._mul_bool(prev, 1 - renov_action, 1) + not_renov_keep = self._mul_bool(prev, 1 - renov_total, 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) + not_renov_keep = self._mul_bool(prev, 1 - renov_total, 1) m.Add(type_active[ct][t] == not_renov_keep) # --- Upgrade state ------------------------------------------------- @@ -536,6 +692,19 @@ class _Builder: m.Add(bf >= bf_src + type_gate + (1 - keep) - 2) builder_free[(u, t)] = bf ni = ai + bf + if u == "infrastructure": + # Industrialist Governor: installs Infrastructure for free + # this turn, regardless of the City's Action. + inf_src = self._gov_attr_bool( + ci, t, lambda a: a.grants_infrastructure, "indinfra") + if inf_src is not None: + bf = m.NewBoolVar(f"indinfrafree_{ci}_t{t}") + # bf = inf_src AND not already installed + m.Add(bf <= inf_src) + m.Add(bf <= 1 - keep) + m.Add(bf >= inf_src + (1 - keep) - 1) + 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) @@ -583,13 +752,29 @@ class _Builder: # reduced if infra present: base*ni - infra_prev*ni discounted = self._mul_bool(infra_prev, ni, 1) cost_terms.append(base * ni - discounted) + steel_spent = sum(cost_terms) if cost_terms else 0 if cost_terms: - self._add_delta("steel", t, -sum(cost_terms)) + self._add_delta("steel", t, -steel_spent) # 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) + steel_spent = steel_spent + AIRSHIP_COST_STEEL * launch + + # Prodigy Governor: when Upgrading or Launching an Airship, refund + # up to N Steel afterwards (N = min(refund cap, Steel spent)). + prodigy_agents = [a.steel_refund for a in self.p.agents if a.steel_refund] + prodigy = self._gov_attr_bool( + ci, t, lambda a: a.steel_refund, "prodigy") + if prodigy is not None: + cap = max(prodigy_agents) + spent_var = m.NewIntVar(0, AIRSHIP_COST_STEEL + 8, f"steelspent_{ci}_t{t}") + m.Add(spent_var == steel_spent) + capped = m.NewIntVar(0, cap, f"refundcap_{ci}_t{t}") + m.AddMinEquality(capped, [spent_var, m.NewConstant(cap)]) + refund = self._mul_bool(capped, prodigy, cap) + self._add_delta("steel", t, refund) # --- Overwork "no collect next turn" lock -------------------------- for t in range(T - 1): @@ -606,26 +791,39 @@ class _Builder: # --- 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)]) + def _gov_attr_bool(self, ci: int, t: int, pred, tag: str = "gov"): + """Bool that is 1 iff a Governor of City ``ci`` on Turn ``t`` satisfies + ``pred(agent)``. Returns None when no such Agent could govern here. + + Each City has at most one Governor per Turn, so the sum is itself a + boolean.""" + contrib = [ + self.gov[(ai, ci, t)] + for ai, a in enumerate(self.p.agents) + if pred(a) and (ai, ci, t) in self.gov + ] if not contrib: return None - b = self.m.NewBoolVar(f"freeup_c{ci}_t{t}") + b = self.m.NewBoolVar(f"{tag}_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)]) + def _free_upgrade_bool(self, ci: int, t: int): + return self._gov_attr_bool(ci, t, lambda a: a.free_upgrade, "freeup") + + def _bonus_collect_expr(self, ci: int, t: int, attr: str): + """Sum of ``agent.`` over Agents governing City ``ci`` on Turn + ``t`` (each such Agent's per-Collection bonus). None if no contributor.""" + terms = [ + getattr(a, attr) * self.gov[(ai, ci, t)] + for ai, a in enumerate(self.p.agents) + if getattr(a, attr) and (ai, ci, t) in self.gov + ] return sum(terms) if terms else None + def _bonus_tg_expr(self, ci: int, t: int): + return self._bonus_collect_expr(ci, t, "bonus_trade_goods") + def _build_collection(self, ci, city, installed, type_active): """Add per-turn resource deltas from Collection, for each possible type.""" m = self.m @@ -690,12 +888,22 @@ class _Builder: 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)) + # ---- per-Collection bonuses from governor agents --------------- + # Each is applied whenever the City Collects (any type) while + # governed. A bonus Governor occupies the City's only Governor slot, + # so these never co-occur with the Planner's Overwork doubling. + # Baron -> +N Trade Goods (N = bonus per Bastion) + # Artificer -> +1 Trade Good + # Capitalist -> +2 Capital + # Vinter -> +2 Luxuries + for resource, attr in ( + ("trade_goods", "bonus_trade_goods"), + ("capital", "bonus_capital"), + ("luxuries", "bonus_luxuries"), + ): + bonus = self._bonus_collect_expr(ci, t, attr) + if bonus is not None: + self._add_delta(resource, t, self._gate_expr(bonus, collect)) # store detail self._collect_detail[(ci, t)] = dict( @@ -732,6 +940,15 @@ class _Builder: ow = self.overwork[(ci, t)] harv = installed["harvester"][t] overflow = installed["overflow_vats"][t] + # Metallurgist Governor grants the Overflow Vats effect while + # governing this (Foundry) City; it does not stack with the actual + # Upgrade, so the effective flag is just the OR of the two. + metal = self._gov_attr_bool( + ci, t, lambda a: a.grants_overflow_vats, "metal") + if metal is not None: + overflow_eff = m.NewBoolVar(f"overflow_eff_{ci}_t{t}") + m.AddMaxEquality(overflow_eff, [overflow, metal]) + overflow = overflow_eff 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) @@ -826,6 +1043,39 @@ class _Builder: self._add_delta(r, t, c) # +1 target resource self._add_delta("trade_goods", t, -c) # -1 Trade Good + def _build_onetime_governor_bonuses(self): + """Courier: the first Turn the Agent governs any City, grant a one-time + ``{resource: amount}`` bonus. Added to the resource deltas before the + balance is built.""" + m = self.m + T = self.T + ncities = len(self.p.cities) + for ai, a in enumerate(self.p.agents): + if not a.onetime_governor_bonus: + continue + # gov_any[t]: this Agent governs some City on Turn t (0/1, since an + # Agent governs at most one City per Turn). + gov_any = [] + for t in range(T): + terms = [self.gov[(ai, ci, t)] for ci in range(ncities) + if (ai, ci, t) in self.gov] + ga = m.NewBoolVar(f"govany_a{ai}_t{t}") + m.Add(ga == (sum(terms) if terms else 0)) + gov_any.append(ga) + for t in range(T): + first = m.NewBoolVar(f"firstgov_a{ai}_t{t}") + if t == 0: + m.Add(first == gov_any[0]) + else: + ever_before = m.NewBoolVar(f"evergov_a{ai}_t{t}") + m.AddMaxEquality(ever_before, gov_any[:t]) + m.Add(first <= gov_any[t]) + m.Add(first <= 1 - ever_before) + m.Add(first >= gov_any[t] - ever_before) + for r, amt in a.onetime_governor_bonus.items(): + if r in RESOURCES and amt: + self._add_delta(r, t, amt * first) + def _build_resource_balance(self): m = self.m T = self.T @@ -840,60 +1090,129 @@ class _Builder: m.Add(v == prev + sum(deltas)) # resources must never go negative (enforced by domain >= 0). - def _build_objective(self): + def _build_provisioner_electrum(self): + """Provisioner: +1.5 Electrum each Turn it governs a City. + + Electrum is never spent in this model (only accumulated for scoring), + so the bonus is layered on top of the integer Electrum pool as an + ``electrum_eff[t]`` amount used by scoring and constraints. The gain is + tracked in half-units (1.5 = 3 half-units); a partial trailing 0.5 from + an odd number of governing Turns is floored.""" m = self.m T = self.T - obj = self.p.objective - terms = [] + ncities = len(self.p.cities) + # default: no Provisioner effect, effective Electrum == the pool. + self.electrum_eff = list(self.res["electrum"]) + half_terms = {t: [] for t in range(T)} + any_half = False + for ai, a in enumerate(self.p.agents): + if not a.governor_electrum_half: + continue + for ci in range(ncities): + for t in range(T): + if (ai, ci, t) in self.gov: + half_terms[t].append( + a.governor_electrum_half * self.gov[(ai, ci, t)]) + any_half = True + if not any_half: + return + per_turn_max = sum(a.governor_electrum_half for a in self.p.agents) + bound = per_turn_max * T + 2 + eff = [] + cum_prev = 0 + for t in range(T): + cum = m.NewIntVar(0, bound, f"elechalf_cum_t{t}") + m.Add(cum == cum_prev + sum(half_terms[t])) + cum_prev = cum + half = m.NewIntVar(0, bound, f"elechalf_t{t}") + m.AddDivisionEquality(half, cum, 2) + e = m.NewIntVar(0, self.MAXR + bound, f"electrum_eff_t{t}") + m.Add(e == self.res["electrum"][t] + half) + eff.append(e) + self.electrum_eff = eff + _OPS = {">=", "<=", "=="} + + def _resource_at(self, resource: str, turn: Optional[int]): + """Resource amount var at END of ``turn`` (None => final Turn). + + ``resource`` may be "renown" only for the final Turn, since per-Turn + Renown is not tracked.""" + T = self.T + t = T - 1 if turn is None else turn + if not (0 <= t < T): + raise ValueError(f"turn {turn} out of range 0..{T - 1}") + if resource == "renown": + if turn is not None and turn != T - 1: + raise ValueError("'renown' is only available on the final Turn") + return self.renown_total + if resource not in RESOURCES: + raise ValueError(f"Unknown resource: {resource!r}") + if resource == "electrum": + # Effective Electrum includes the Provisioner Governor bonus. + return self.electrum_eff[t] + return self.res[resource][t] + + def _build_renown_total(self): + m = self.m # 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 = sum of city final renown + extra + launched airships. 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 + def _build_resource_constraints(self): + m = self.m + for c in self.p.resource_constraints: + op = c.get("op", ">=") + if op not in self._OPS: + raise ValueError(f"resource_constraints op must be one of {self._OPS}") + var = self._resource_at(c["resource"], c.get("turn")) + value = int(c["value"]) + if op == ">=": + m.Add(var >= value) + elif op == "<=": + m.Add(var <= value) + else: + m.Add(var == value) + + def _build_objective(self): + m = self.m + T = self.T + obj = self.p.objective + terms = [] + # 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 + # Electrum uses its effective amount (incl. the Provisioner bonus). + self.final_amt = {r: self._resource_at(r, None) for r in RESOURCES} 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}]") + # Every scoring term is a (turn, resource, scalar, log_mapping) tuple; + # legacy mode/scalars are lowered into final-Turn terms by all_terms(). + for term in obj.all_terms(): + if not term.scalar: + continue + amt_var = self._resource_at(term.resource, term.turn) + if term.log_mapping is None: + terms.append(scaled(term.scalar) * amt_var) + else: + table = term.log_mapping # 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}") + tag = f"{term.resource}_t{term.turn if term.turn is not None else T - 1}" + idx = m.NewIntVar(0, len(table) - 1, f"idx_{tag}") m.AddMinEquality(idx, [amt_var, m.NewConstant(len(table) - 1)]) - val = m.NewIntVar(min(vals), max(vals), f"logval_{key}") + val = m.NewIntVar(min(vals), max(vals), f"logval_{tag}") m.AddElement(idx, vals, val) - terms.append(int(round(s)) * val) - else: - raise ValueError(f"Unknown objective mode: {obj.mode}") + terms.append(int(round(term.scalar)) * val) m.Maximize(sum(terms)) @@ -932,7 +1251,12 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio if solver.Value(v) == 1: chosen = a break - if chosen is None or chosen == Action.IDLE: + # Foreman may add a Renovate that does not consume the Action. + foreman_renov = ( + (ci, t) in b._foreman_renov + and bool(solver.Value(b._foreman_renov[(ci, t)])) + ) + if (chosen is None or chosen == Action.IDLE) and not foreman_renov: continue detail = "" governor = "" @@ -965,8 +1289,17 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio detail = f"renovate -> {ct}" elif chosen == Action.LAUNCH: detail = "launch airship" + action_label = chosen.value if chosen is not None else Action.IDLE.value + if foreman_renov: + rc = b._renovate_choice.get((ci, t), {}) + tgt = next( + (ct for ct, var in rc.items() if solver.Value(var) == 1), None) + rdetail = f"renovate -> {tgt}" if tgt else "renovate" + detail = f"{detail} + {rdetail}" if detail else rdetail + if chosen is None or chosen == Action.IDLE: + action_label = Action.RENOVATE.value plan.append(CityTurnPlan( - turn=t, city=city.name, action=chosen.value, + turn=t, city=city.name, action=action_label, detail=detail, governor=governor, overwork=overwork, )) @@ -978,6 +1311,12 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio amt = solver.Value(var) if amt: conversions.append({"turn": t, "resource": r, "amount": amt}) + resources_by_turn = [] + for t in range(T): + row = {"turn": t} + for r in RESOURCES: + row[r] = float(solver.Value(b._resource_at(r, t))) + resources_by_turn.append(row) return Solution( status=status_name, objective_value=solver.ObjectiveValue() / OBJ_SCALE, @@ -985,6 +1324,7 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio final_renown_total=int(solver.Value(b.renown_total)), plan=sorted(plan, key=lambda p: (p.turn, p.city)), trade_conversions=conversions, + resources_by_turn=resources_by_turn, ) @@ -1006,7 +1346,12 @@ def problem_from_dict(d: dict) -> Problem: 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", {})) + obj_d = dict(d.get("objective", {})) + if "terms" in obj_d: + obj_d["terms"] = [ + t if isinstance(t, ScoreTerm) else ScoreTerm(**t) for t in obj_d["terms"] + ] + obj = Objective(**obj_d) 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)