added other agents
This commit is contained in:
parent
b9cdb3bb26
commit
1a27e9048b
5 changed files with 1470 additions and 62 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__
|
||||||
|
.venv
|
||||||
Binary file not shown.
441
agents.txt
Normal file
441
agents.txt
Normal file
|
|
@ -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
|
||||||
|
|
||||||
622
main.py
622
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"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Days Without Strife - Planner</title>
|
||||||
|
<style>
|
||||||
|
* {box-sizing: border-box;}
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body { font-family: system-ui, sans-serif; margin: 0; padding: 1.5rem;
|
||||||
|
max-width: 100vw; margin-inline: auto; line-height: 1.4; }
|
||||||
|
h1 { margin-top: 0; }
|
||||||
|
fieldset { margin: 1rem 0; border: 1px solid #8884; border-radius: 8px; }
|
||||||
|
legend { font-weight: 600; padding: 0 .4rem; }
|
||||||
|
label { display: inline-block; }
|
||||||
|
input, select, textarea { font: inherit; padding: .2rem .35rem; }
|
||||||
|
input[type=number] { width: 6rem; }
|
||||||
|
button { font: inherit; padding: .3rem .7rem; border-radius: 6px;
|
||||||
|
border: 1px solid #8886; cursor: pointer; background: #8881; }
|
||||||
|
button.primary { background: #2563eb; color: #fff; border-color: #2563eb;
|
||||||
|
padding: .5rem 1.2rem; font-weight: 600; }
|
||||||
|
.mini { padding: .1rem .45rem; font-size: .85rem; }
|
||||||
|
textarea { width: 100%; box-sizing: border-box; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(180px,1fr));
|
||||||
|
gap: .6rem 1rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
|
||||||
|
.field > span { font-size: .8rem; opacity: .8; }
|
||||||
|
pre { background: #8881; padding: 1rem; border-radius: 8px; overflow: auto; }
|
||||||
|
.err { color: #dc2626; white-space: pre-wrap; }
|
||||||
|
.help { font-size: .8rem; opacity: .75; margin: .2rem 0 0; }
|
||||||
|
|
||||||
|
/* Each input "row" is a card whose labeled fields flow in an auto-fit grid,
|
||||||
|
so wide rows wrap onto multiple lines instead of overflowing the page. */
|
||||||
|
.cards { display: grid; gap: .6rem; }
|
||||||
|
.card { border: 1px solid #8884; border-radius: 8px; padding: .55rem .7rem;
|
||||||
|
display: grid; gap: .45rem .8rem; align-items: end;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||||
|
.card input, .card select, .card textarea { width: 100%; }
|
||||||
|
.card input[type=checkbox] { width: auto; }
|
||||||
|
.card .check { flex-direction: row; align-items: center; gap: .35rem; }
|
||||||
|
.card-actions { align-self: start; justify-self: end; }
|
||||||
|
.log-cell { display: none; }
|
||||||
|
.card.log-on .log-cell { display: flex; }
|
||||||
|
.card.log-on .linear-only { opacity: .4; }
|
||||||
|
|
||||||
|
/* Output tables: a CSS grid kept inside a scroll container so it never
|
||||||
|
stretches the page wider than it should. */
|
||||||
|
.gtable-wrap { overflow-x: auto; margin-top: .3rem; }
|
||||||
|
.gtable { display: grid; width: max-content; min-width: 100%; }
|
||||||
|
.gtable > div { padding: .25rem .5rem; border-bottom: 1px solid #8883; }
|
||||||
|
.gtable > .gh { font-size: .85rem; opacity: .8; border-bottom: 1px solid #8886; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Days Without Strife — Planner Optimizer</h1>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Game</legend>
|
||||||
|
<div class="grid">
|
||||||
|
<label class="field"><span>Turns</span><input id="turns" type="number" min="1" value="5" oninput="refreshTurnSelects()"></label>
|
||||||
|
<label class="field"><span>Extra renown (constant)</span><input id="extra_renown" type="number" value="0"></label>
|
||||||
|
<label class="field"><span>Airships already launched</span><input id="airships_launched" type="number" min="0" value="0"></label>
|
||||||
|
<label class="field"><span>Max resource</span><input id="max_resource" type="number" min="1" value="300"></label>
|
||||||
|
<label class="field"><span>Max vat</span><input id="max_vat" type="number" min="1" value="12"></label>
|
||||||
|
<label class="field"><span>Solver time limit (s)</span><input id="time" type="number" min="1" value="30"></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Starting resources</legend>
|
||||||
|
<div class="grid" id="start"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Tradeable into (Trade Goods convert 1-for-1 for scoring)</legend>
|
||||||
|
<div id="tradeable"></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Cities</legend>
|
||||||
|
<p class="help">The <b>name</b> is the city's unique identifier — it labels the
|
||||||
|
output plan and is what an agent's "forced city" refers to. <b>Upgrades</b> are the
|
||||||
|
ones already installed at the start; the available choices follow the city's type.</p>
|
||||||
|
<div id="cities" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addCity()">+ city</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Agents</legend>
|
||||||
|
<p class="help">Pick a known agent; its governor behaviour is implicit and
|
||||||
|
shown in the <b>Effect</b> column. The <b>name</b> identifies it and is what a
|
||||||
|
forced city refers back to. All effects apply while the agent is Governor of a
|
||||||
|
city (at most one city per turn).</p>
|
||||||
|
<div id="agents" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addAgent()">+ agent</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Objective — scoring terms</legend>
|
||||||
|
<p class="help">Each term scores a resource (or <code>renown</code>) at the end of a
|
||||||
|
turn (blank turn = final turn). For a <b>log</b> term, write a JS expression that
|
||||||
|
evals to a one-argument function, e.g. <code>(x) => Math.log2(x + 1)</code>.
|
||||||
|
It is called over amounts <code>0..max_resource</code> in the browser to build the
|
||||||
|
lookup table.</p>
|
||||||
|
<div id="terms" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addTerm()">+ term</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Resource constraints</legend>
|
||||||
|
<div id="constraints" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button class="primary" type="button" onclick="run()">Solve</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="error" class="err"></div>
|
||||||
|
<div id="output"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const RESOURCES = ["capital","luxuries","steel","brass","electrum","trade_goods","express"];
|
||||||
|
const SCORE_KEYS = RESOURCES.concat(["renown"]);
|
||||||
|
const CITY_TYPES = ["hub","foundry","monument","metropolis"];
|
||||||
|
const ACTIONS = ["idle","collect","renovate","upgrade","launch"];
|
||||||
|
// Upgrades that apply to any city type, plus the type-specific "3rd" upgrade.
|
||||||
|
const UNIVERSAL_UPGRADES = ["infrastructure","harvester","fortification"];
|
||||||
|
const TYPE_UPGRADE = {hub:"fine_dining", foundry:"overflow_vats",
|
||||||
|
metropolis:"transit_authority", monument:"propaganda"};
|
||||||
|
// Known agents and the implicit logic each one carries.
|
||||||
|
const AGENT_PRESETS = {
|
||||||
|
Planner: {overwork: true},
|
||||||
|
Baron: {bonus_trade_goods: 3}, // +N Trade Goods per Bastion on collect
|
||||||
|
Builder: {free_upgrade: true},
|
||||||
|
Capitalist: {bonus_capital: 2}, // +2 Capital on collect
|
||||||
|
Vinter: {bonus_luxuries: 2}, // +2 Luxuries on collect
|
||||||
|
Artificer: {bonus_trade_goods: 1}, // +1 Trade Good on collect
|
||||||
|
Metallurgist: {grants_overflow_vats: true}, // Overflow Vats on a Foundry
|
||||||
|
Industrialist:{grants_infrastructure: true}, // free Infrastructure
|
||||||
|
Foreman: {free_renovate: true}, // Renovate without spending the Action
|
||||||
|
Prodigy: {steel_refund: 2}, // refund <=2 Steel on Upgrade/Launch
|
||||||
|
Provisioner: {governor_electrum_half: 3}, // +1.5 Electrum / governing turn
|
||||||
|
Courier: {onetime_governor_bonus: {capital: 3, steel: 3, brass: 3}},
|
||||||
|
};
|
||||||
|
// Short human-readable effect, shown inline next to each agent's dropdown.
|
||||||
|
const AGENT_DESC = {
|
||||||
|
Planner: "Overwork: double Collection & waive Capital cost; locks next-turn Collect",
|
||||||
|
Baron: "+N Trade Goods per Bastion on Collect",
|
||||||
|
Builder: "free type-specific Upgrade",
|
||||||
|
Capitalist: "+2 Capital on Collect",
|
||||||
|
Vinter: "+2 Luxuries on Collect",
|
||||||
|
Artificer: "+1 Trade Good on Collect",
|
||||||
|
Metallurgist: "Overflow Vats effect on a Foundry",
|
||||||
|
Industrialist:"free Infrastructure Upgrade",
|
||||||
|
Foreman: "Renovate without spending the Action",
|
||||||
|
Prodigy: "refund ≤2 Steel on Upgrade/Launch",
|
||||||
|
Provisioner: "+1.5 Electrum per governing Turn",
|
||||||
|
Courier: "one-time +3 Capital/Steel/Brass on first govern",
|
||||||
|
};
|
||||||
|
|
||||||
|
function el(tag, attrs={}, children=[]) {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
for (const [k,v] of Object.entries(attrs)) {
|
||||||
|
if (k === "class") e.className = v;
|
||||||
|
else if (k === "html") e.innerHTML = v;
|
||||||
|
else if (k.startsWith("on")) e[k] = v;
|
||||||
|
else e.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of [].concat(children)) e.append(c);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function num(value, attrs={}) { return el("input", {type:"number", value, ...attrs}); }
|
||||||
|
function selectEl(opts, value) {
|
||||||
|
const s = el("select");
|
||||||
|
for (const o of opts) {
|
||||||
|
const opt = el("option", {value:o}, o);
|
||||||
|
if (o === value) opt.selected = true;
|
||||||
|
s.append(opt);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function removeBtn(row) {
|
||||||
|
return el("button", {class:"mini", type:"button",
|
||||||
|
onclick:() => row.remove()}, "×");
|
||||||
|
}
|
||||||
|
// A labeled field inside a card (label stacked above its control).
|
||||||
|
function field(label, control) {
|
||||||
|
return el("label", {class:"field"}, [el("span", {}, label), control]);
|
||||||
|
}
|
||||||
|
// A checkbox field (checkbox beside its label, laid out horizontally).
|
||||||
|
function checkField(label, cb) {
|
||||||
|
return el("label", {class:"field check"}, [cb, el("span", {}, label)]);
|
||||||
|
}
|
||||||
|
// Build an output grid "table": a CSS grid (one column per header) wrapped in
|
||||||
|
// a horizontally-scrollable container so it never widens the page.
|
||||||
|
function gridTable(headers, rows) {
|
||||||
|
const wrap = el("div", {class:"gtable-wrap"});
|
||||||
|
const g = el("div", {class:"gtable",
|
||||||
|
style:`grid-template-columns: repeat(${headers.length}, auto)`});
|
||||||
|
for (const h of headers) g.append(el("div", {class:"gh"}, String(h)));
|
||||||
|
for (const r of rows)
|
||||||
|
for (const c of r) g.append(el("div", {}, String(c)));
|
||||||
|
wrap.append(g);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- turn dropdowns -------------------------------------------------------
|
||||||
|
// Turns are chosen from the current "Turns" count rather than typed freehand.
|
||||||
|
// Every turn <select> registers here so its options can be rebuilt (preserving
|
||||||
|
// the current selection) whenever the Turns count changes. value "" = final.
|
||||||
|
const turnSelects = new Set();
|
||||||
|
function turnsCount() { return +document.getElementById("turns").value || 0; }
|
||||||
|
function fillTurnOptions(sel) {
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
sel.append(el("option", {value:""}, "final"));
|
||||||
|
for (let t = 0; t < turnsCount(); t++)
|
||||||
|
sel.append(el("option", {value:String(t)}, "turn " + t));
|
||||||
|
// Keep the prior choice if it still exists, else fall back to "final".
|
||||||
|
sel.value = [...sel.options].some(o => o.value === prev) ? prev : "";
|
||||||
|
}
|
||||||
|
function turnSelect(value) {
|
||||||
|
const sel = el("select");
|
||||||
|
turnSelects.add(sel);
|
||||||
|
fillTurnOptions(sel);
|
||||||
|
if (value !== undefined && value !== null && value !== "") sel.value = String(value);
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
function refreshTurnSelects() {
|
||||||
|
for (const sel of turnSelects) {
|
||||||
|
if (!sel.isConnected) { turnSelects.delete(sel); continue; }
|
||||||
|
fillTurnOptions(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- starting resources & tradeable ---
|
||||||
|
const startInputs = {};
|
||||||
|
const tradeInputs = {};
|
||||||
|
for (const r of RESOURCES) {
|
||||||
|
const inp = num(r === "express" ? 0 : 3, {min:0});
|
||||||
|
startInputs[r] = inp;
|
||||||
|
document.getElementById("start").append(
|
||||||
|
el("label", {class:"field"}, [el("span", {}, r), inp]));
|
||||||
|
|
||||||
|
const cb = el("input", {type:"checkbox"});
|
||||||
|
if (r !== "express") cb.checked = true;
|
||||||
|
tradeInputs[r] = cb;
|
||||||
|
document.getElementById("tradeable").append(
|
||||||
|
el("label", {style:"margin-right:1rem"}, [cb, " " + r]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cities ---
|
||||||
|
function addCity(c={}) {
|
||||||
|
const card = el("div", {class:"card"});
|
||||||
|
const name = el("input", {value:c.name||""});
|
||||||
|
const type = selectEl(CITY_TYPES, c.type||"hub");
|
||||||
|
const renown = num(c.renown ?? "", {min:1, placeholder:"auto"});
|
||||||
|
const vs = num(c.vat_steel||0, {min:0}), vb = num(c.vat_brass||0, {min:0}),
|
||||||
|
ve = num(c.vat_electrum||0, {min:0});
|
||||||
|
const reno = el("input", {type:"checkbox"}); reno.checked = c.can_renovate !== false;
|
||||||
|
const forced = el("input", {value:"", placeholder:"0:upgrade"});
|
||||||
|
const avail = el("input", {value:"", placeholder:""});
|
||||||
|
|
||||||
|
// Upgrade checkboxes: the 3 universal upgrades plus the current type's
|
||||||
|
// type-specific upgrade. State persists across type changes (hidden boxes
|
||||||
|
// are simply ignored), and only the currently-allowed checked ones count.
|
||||||
|
const preset = new Set(c.upgrades || []);
|
||||||
|
const upBoxes = {};
|
||||||
|
const upWrap = el("div");
|
||||||
|
function allowedUpgrades() {
|
||||||
|
return UNIVERSAL_UPGRADES.concat([TYPE_UPGRADE[type.value]]);
|
||||||
|
}
|
||||||
|
function renderUpgrades() {
|
||||||
|
upWrap.innerHTML = "";
|
||||||
|
for (const u of allowedUpgrades()) {
|
||||||
|
if (!upBoxes[u]) {
|
||||||
|
const cb = el("input", {type:"checkbox"});
|
||||||
|
if (preset.has(u)) cb.checked = true;
|
||||||
|
upBoxes[u] = cb;
|
||||||
|
}
|
||||||
|
upWrap.append(el("label", {style:"display:block;font-size:.85rem"},
|
||||||
|
[upBoxes[u], " " + u]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type.onchange = renderUpgrades;
|
||||||
|
renderUpgrades();
|
||||||
|
|
||||||
|
card._get = () => {
|
||||||
|
const o = {name:name.value, type:type.value};
|
||||||
|
if (renown.value !== "") o.renown = +renown.value;
|
||||||
|
if (+vs.value) o.vat_steel = +vs.value;
|
||||||
|
if (+vb.value) o.vat_brass = +vb.value;
|
||||||
|
if (+ve.value) o.vat_electrum = +ve.value;
|
||||||
|
const u = allowedUpgrades().filter(name => upBoxes[name] && upBoxes[name].checked);
|
||||||
|
if (u.length) o.upgrades = u;
|
||||||
|
if (!reno.checked) o.can_renovate = false;
|
||||||
|
const fa = parsePairs(forced.value);
|
||||||
|
if (Object.keys(fa).length) o.forced_action = fa;
|
||||||
|
const at = parseInts(avail.value);
|
||||||
|
if (at) o.available_turns = at;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
card.append(
|
||||||
|
field("Name", name),
|
||||||
|
field("Type", type),
|
||||||
|
field("Renown", renown),
|
||||||
|
field("Vat steel", vs),
|
||||||
|
field("Vat brass", vb),
|
||||||
|
field("Vat electrum", ve),
|
||||||
|
field("Upgrades (already installed)", upWrap),
|
||||||
|
checkField("Can renovate", reno),
|
||||||
|
field("Forced actions (turn:action, csv)", forced),
|
||||||
|
field("Avail turns (csv, blank=all)", avail),
|
||||||
|
el("div", {class:"card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("cities").append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- agents ---
|
||||||
|
function addAgent(a={}) {
|
||||||
|
const card = el("div", {class:"card"});
|
||||||
|
// Identify the agent by its preset flags; the logic is implicit to the choice.
|
||||||
|
let kind = a.kind;
|
||||||
|
if (!kind) {
|
||||||
|
kind = Object.keys(AGENT_PRESETS).find(
|
||||||
|
k => Object.keys(AGENT_PRESETS[k]).some(f => a[f] !== undefined)) || "Planner";
|
||||||
|
}
|
||||||
|
const type = selectEl(Object.keys(AGENT_PRESETS), kind);
|
||||||
|
const name = el("input", {value:a.name || kind});
|
||||||
|
const desc = el("span", {class:"help"});
|
||||||
|
const bastions = num(a.bonus_trade_goods || 3, {min:0});
|
||||||
|
const forced = el("input", {value:"", placeholder:"0:Aridias"});
|
||||||
|
const avail = el("input", {value:""});
|
||||||
|
|
||||||
|
let nameEdited = !!a.name;
|
||||||
|
name.oninput = () => { nameEdited = true; };
|
||||||
|
function syncType() {
|
||||||
|
if (!nameEdited) name.value = type.value;
|
||||||
|
bastions.disabled = (type.value !== "Baron");
|
||||||
|
desc.textContent = AGENT_DESC[type.value] || "";
|
||||||
|
}
|
||||||
|
type.onchange = syncType;
|
||||||
|
syncType();
|
||||||
|
|
||||||
|
card._get = () => {
|
||||||
|
const o = {name:name.value, ...AGENT_PRESETS[type.value]};
|
||||||
|
if (type.value === "Baron") o.bonus_trade_goods = +bastions.value;
|
||||||
|
const fc = parsePairs(forced.value, true);
|
||||||
|
if (Object.keys(fc).length) o.forced_city = fc;
|
||||||
|
const at = parseInts(avail.value);
|
||||||
|
if (at) o.available_turns = at;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
card.append(
|
||||||
|
field("Agent", type),
|
||||||
|
field("Name", name),
|
||||||
|
field("Effect", desc),
|
||||||
|
field("Bastions (Baron only)", bastions),
|
||||||
|
field("Forced city (turn:city, csv)", forced),
|
||||||
|
field("Avail turns (csv, blank=all)", avail),
|
||||||
|
el("div", {class:"card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("agents").append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- objective terms ---
|
||||||
|
function addTerm(t={}) {
|
||||||
|
const card = el("div", {class:"card"});
|
||||||
|
const res = selectEl(SCORE_KEYS, t.resource||"renown");
|
||||||
|
const scalar = num(t.scalar ?? 1, {step:"any"});
|
||||||
|
const turn = turnSelect(t.turn);
|
||||||
|
const isLog = el("input", {type:"checkbox"});
|
||||||
|
isLog.checked = !!t.log_mapping;
|
||||||
|
const toggle = () => card.classList.toggle("log-on", isLog.checked);
|
||||||
|
isLog.onchange = toggle;
|
||||||
|
const expr = el("textarea", {rows:1, placeholder:"(x) => Math.log2(x + 1)"});
|
||||||
|
if (t._expr) expr.value = t._expr;
|
||||||
|
card._get = () => {
|
||||||
|
const o = {resource:res.value, scalar:+scalar.value};
|
||||||
|
if (turn.value !== "") o.turn = +turn.value;
|
||||||
|
if (isLog.checked) o.log_mapping = buildLogTable(expr.value);
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
const resF = field("Resource", res); resF.classList.add("linear-only");
|
||||||
|
const turnF = field("Turn", turn); turnF.classList.add("linear-only");
|
||||||
|
const exprF = field("JS function of amount x", expr); exprF.classList.add("log-cell");
|
||||||
|
card.append(
|
||||||
|
resF,
|
||||||
|
field("Scalar", scalar),
|
||||||
|
turnF,
|
||||||
|
checkField("Log?", isLog),
|
||||||
|
exprF,
|
||||||
|
el("div", {class:"card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("terms").append(card);
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eval the expression ONCE into a function, then call it over the amounts
|
||||||
|
// the lookup table needs (0..max_resource).
|
||||||
|
function buildLogTable(exprStr) {
|
||||||
|
let fn;
|
||||||
|
try {
|
||||||
|
fn = eval(exprStr);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("Could not eval log expression: " + e.message);
|
||||||
|
}
|
||||||
|
if (typeof fn !== "function")
|
||||||
|
throw new Error("Log expression must eval to a function, got " + typeof fn);
|
||||||
|
const max = +document.getElementById("max_resource").value || 0;
|
||||||
|
const table = [];
|
||||||
|
for (let x = 0; x <= max; x++) {
|
||||||
|
const v = Number(fn(x));
|
||||||
|
table.push(Number.isFinite(v) ? v : 0);
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resource constraints ---
|
||||||
|
function addConstraint(c={}) {
|
||||||
|
const card = el("div", {class:"card"});
|
||||||
|
const res = selectEl(SCORE_KEYS, c.resource||"capital");
|
||||||
|
const op = selectEl([">=","<=","=="], c.op||">=");
|
||||||
|
const value = num(c.value ?? 0);
|
||||||
|
const turn = turnSelect(c.turn);
|
||||||
|
card._get = () => {
|
||||||
|
const o = {resource:res.value, op:op.value, value:+value.value};
|
||||||
|
if (turn.value !== "") o.turn = +turn.value;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
card.append(
|
||||||
|
field("Resource", res),
|
||||||
|
field("Op", op),
|
||||||
|
field("Value", value),
|
||||||
|
field("Turn (blank=final)", turn),
|
||||||
|
el("div", {class:"card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("constraints").append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parsing helpers ---
|
||||||
|
function parseInts(s) {
|
||||||
|
const out = s.split(",").map(x=>x.trim()).filter(Boolean).map(Number);
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
function parsePairs(s, valueIsString=false) {
|
||||||
|
const o = {};
|
||||||
|
for (const part of s.split(",").map(x=>x.trim()).filter(Boolean)) {
|
||||||
|
const [k, v] = part.split(":").map(x=>x.trim());
|
||||||
|
o[+k] = valueIsString ? v : v;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(id) {
|
||||||
|
return [...document.getElementById(id).children].map(r => r._get());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProblem() {
|
||||||
|
const start = {};
|
||||||
|
for (const r of RESOURCES) { const v = +startInputs[r].value; if (v) start[r] = v; }
|
||||||
|
const tradeable_into = RESOURCES.filter(r => tradeInputs[r].checked);
|
||||||
|
return {
|
||||||
|
turns: +document.getElementById("turns").value,
|
||||||
|
extra_renown: +document.getElementById("extra_renown").value,
|
||||||
|
airships_launched: +document.getElementById("airships_launched").value,
|
||||||
|
max_resource: +document.getElementById("max_resource").value,
|
||||||
|
max_vat: +document.getElementById("max_vat").value,
|
||||||
|
start,
|
||||||
|
tradeable_into,
|
||||||
|
cities: collect("cities"),
|
||||||
|
agents: collect("agents"),
|
||||||
|
objective: { terms: collect("terms") },
|
||||||
|
resource_constraints: collect("constraints"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const errBox = document.getElementById("error");
|
||||||
|
const out = document.getElementById("output");
|
||||||
|
errBox.textContent = ""; out.innerHTML = "";
|
||||||
|
let problem, time;
|
||||||
|
try {
|
||||||
|
problem = buildProblem();
|
||||||
|
time = +document.getElementById("time").value;
|
||||||
|
} catch (e) { errBox.textContent = e.message; return; }
|
||||||
|
out.textContent = "Solving…";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/solve", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({problem, max_time_seconds: time}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) { out.innerHTML = ""; errBox.textContent = data.error || "Server error"; return; }
|
||||||
|
renderSolution(data);
|
||||||
|
} catch (e) { out.innerHTML = ""; errBox.textContent = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSolution(s) {
|
||||||
|
const out = document.getElementById("output");
|
||||||
|
out.innerHTML = "";
|
||||||
|
out.append(el("h2", {}, "Solution"));
|
||||||
|
out.append(el("p", {html:
|
||||||
|
`<b>Status:</b> ${s.status} <b>Objective:</b> ${s.objective_value} ` +
|
||||||
|
`<b>Final renown total:</b> ${s.final_renown_total}`}));
|
||||||
|
|
||||||
|
const fr = el("p", {});
|
||||||
|
fr.append(el("b", {}, "Final resources: "));
|
||||||
|
fr.append(document.createTextNode(
|
||||||
|
Object.entries(s.final_resources).map(([k,v])=>`${k}=${fmtNum(v)}`).join(", ") || "—"));
|
||||||
|
out.append(fr);
|
||||||
|
|
||||||
|
if (s.plan && s.plan.length) {
|
||||||
|
out.append(el("h3", {}, "Plan"));
|
||||||
|
out.append(gridTable(
|
||||||
|
["Turn","City","Action","Detail","Governor","Overwork"],
|
||||||
|
s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor,
|
||||||
|
p.overwork ? "yes" : ""])));
|
||||||
|
} else {
|
||||||
|
out.append(el("p", {}, "(no actions / no feasible plan)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource amounts at the end of each turn.
|
||||||
|
if (s.resources_by_turn && s.resources_by_turn.length) {
|
||||||
|
const cols = RESOURCES.filter(r => s.resources_by_turn.some(row => row[r] !== undefined));
|
||||||
|
out.append(el("h3", {}, "Resources by turn"));
|
||||||
|
out.append(gridTable(
|
||||||
|
["Turn"].concat(cols),
|
||||||
|
s.resources_by_turn.map(row =>
|
||||||
|
[row.turn].concat(cols.map(r => fmtNum(row[r]))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.trade_conversions && s.trade_conversions.length) {
|
||||||
|
out.append(el("h3", {}, "Trade Goods conversions"));
|
||||||
|
out.append(gridTable(
|
||||||
|
["Turn","Converted into","Amount"],
|
||||||
|
s.trade_conversions.map(c => [c.turn, c.resource, fmtNum(c.amount)])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing ".0" so whole numbers read cleanly.
|
||||||
|
function fmtNum(v) {
|
||||||
|
if (typeof v !== "number") return String(v);
|
||||||
|
return Number.isInteger(v) ? String(v) : String(+v.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- seed with the example problem ---
|
||||||
|
addCity({name:"Aridias", type:"hub", renown:2});
|
||||||
|
addCity({name:"Bearhearth", type:"foundry", renown:2, vat_steel:3, vat_brass:2, vat_electrum:1});
|
||||||
|
addCity({name:"Kingsland", type:"metropolis", renown:4, can_renovate:false});
|
||||||
|
addCity({name:"Roseward", type:"monument", renown:2});
|
||||||
|
addAgent({kind:"Planner"});
|
||||||
|
[["renown",5],["capital",1],["luxuries",1],["steel",1],["brass",1],
|
||||||
|
["electrum",2],["trade_goods",1],["express",3]].forEach(([r,s]) =>
|
||||||
|
addTerm({resource:r, scalar:s}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
467
solve.py
467
solve.py
|
|
@ -43,6 +43,14 @@ Governors / Overwork:
|
||||||
one City per Turn. Generic governor Agents may also grant a free Upgrade
|
one City per Turn. Generic governor Agents may also grant a free Upgrade
|
||||||
or bonus Trade Goods on Collect.
|
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:
|
Objective:
|
||||||
|
|
||||||
For each scored resource n, either ``scalar_n * amount_n`` (linear) or
|
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
|
overwork: bool = False # Planner: double + waive cost
|
||||||
free_upgrade: bool = False # e.g. Brotherhood Builder
|
free_upgrade: bool = False # e.g. Brotherhood Builder
|
||||||
bonus_trade_goods: int = 0 # e.g. Baron: +N Trade Goods on collect
|
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
|
available_turns: Optional[list[int]] = None
|
||||||
# Hard constraint: must govern this city on this turn (turn -> city name).
|
# Hard constraint: must govern this city on this turn (turn -> city name).
|
||||||
forced_city: dict[int, str] = field(default_factory=dict)
|
forced_city: dict[int, str] = field(default_factory=dict)
|
||||||
|
|
@ -209,16 +229,123 @@ class Agent:
|
||||||
installed that Turn)."""
|
installed that Turn)."""
|
||||||
return cls(name=name, free_upgrade=True, **kw)
|
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
|
@dataclass
|
||||||
class Objective:
|
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"
|
mode: str = "linear"
|
||||||
# Per-resource weight. Key is a resource name or "renown".
|
|
||||||
scalars: dict[str, float] = field(default_factory=dict)
|
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)
|
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
|
@dataclass
|
||||||
class Problem:
|
class Problem:
|
||||||
|
|
@ -238,6 +365,10 @@ class Problem:
|
||||||
)
|
)
|
||||||
max_resource: int = DEFAULT_MAX_RESOURCE
|
max_resource: int = DEFAULT_MAX_RESOURCE
|
||||||
max_vat: int = DEFAULT_MAX_VAT
|
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)
|
plan: list[CityTurnPlan] = field(default_factory=list)
|
||||||
# Mid-game Trade Goods conversions: list of {turn, resource, amount}.
|
# Mid-game Trade Goods conversions: list of {turn, resource, amount}.
|
||||||
trade_conversions: list[dict] = field(default_factory=list)
|
trade_conversions: list[dict] = field(default_factory=list)
|
||||||
|
# Resource amounts at the END of each turn: list of {turn, <resource>: 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._collect_detail: dict[tuple[int, int], dict] = {}
|
||||||
self._upgrade_choice: dict[tuple[int, int], dict] = {}
|
self._upgrade_choice: dict[tuple[int, int], dict] = {}
|
||||||
self._renovate_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 ----------------------------------------------------------- #
|
# -- helpers ----------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
@ -316,7 +451,11 @@ class _Builder:
|
||||||
self._build_actions_and_governors()
|
self._build_actions_and_governors()
|
||||||
self._build_city_dynamics()
|
self._build_city_dynamics()
|
||||||
self._build_trade_conversion()
|
self._build_trade_conversion()
|
||||||
|
self._build_onetime_governor_bonuses()
|
||||||
self._build_resource_balance()
|
self._build_resource_balance()
|
||||||
|
self._build_provisioner_electrum()
|
||||||
|
self._build_renown_total()
|
||||||
|
self._build_resource_constraints()
|
||||||
self._build_objective()
|
self._build_objective()
|
||||||
return self.m
|
return self.m
|
||||||
|
|
||||||
|
|
@ -437,6 +576,23 @@ class _Builder:
|
||||||
)
|
)
|
||||||
for t in range(T):
|
for t in range(T):
|
||||||
renov_action = self.act[(ci, t, Action.RENOVATE)]
|
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 = []
|
choices = []
|
||||||
for ct in CityType:
|
for ct in CityType:
|
||||||
if ct == CityType.METROPOLIS:
|
if ct == CityType.METROPOLIS:
|
||||||
|
|
@ -445,7 +601,7 @@ class _Builder:
|
||||||
renov_to[(ct, t)] = r
|
renov_to[(ct, t)] = r
|
||||||
choices.append(r)
|
choices.append(r)
|
||||||
if renovate_allowed and choices:
|
if renovate_allowed and choices:
|
||||||
m.Add(sum(choices) == renov_action)
|
m.Add(sum(choices) == renov_total)
|
||||||
else:
|
else:
|
||||||
m.Add(renov_action == 0)
|
m.Add(renov_action == 0)
|
||||||
for ct in CityType:
|
for ct in CityType:
|
||||||
|
|
@ -465,13 +621,13 @@ class _Builder:
|
||||||
r = renov_to.get((ct, t))
|
r = renov_to.get((ct, t))
|
||||||
if r is not None:
|
if r is not None:
|
||||||
# active(ct,t) == renovated_to_ct OR (prev AND not renovating)
|
# 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.
|
# 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)
|
m.Add(type_active[ct][t] == not_renov_keep + r)
|
||||||
else:
|
else:
|
||||||
# metropolis target impossible; keep only if not renovating.
|
# 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)
|
m.Add(type_active[ct][t] == not_renov_keep)
|
||||||
|
|
||||||
# --- Upgrade state -------------------------------------------------
|
# --- Upgrade state -------------------------------------------------
|
||||||
|
|
@ -536,6 +692,19 @@ class _Builder:
|
||||||
m.Add(bf >= bf_src + type_gate + (1 - keep) - 2)
|
m.Add(bf >= bf_src + type_gate + (1 - keep) - 2)
|
||||||
builder_free[(u, t)] = bf
|
builder_free[(u, t)] = bf
|
||||||
ni = ai + 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.
|
# installed = keep OR new ; can't (re)install if already kept.
|
||||||
m.Add(installed[u][t] == keep + ni)
|
m.Add(installed[u][t] == keep + ni)
|
||||||
m.Add(keep + ni <= 1)
|
m.Add(keep + ni <= 1)
|
||||||
|
|
@ -583,13 +752,29 @@ class _Builder:
|
||||||
# reduced if infra present: base*ni - infra_prev*ni
|
# reduced if infra present: base*ni - infra_prev*ni
|
||||||
discounted = self._mul_bool(infra_prev, ni, 1)
|
discounted = self._mul_bool(infra_prev, ni, 1)
|
||||||
cost_terms.append(base * ni - discounted)
|
cost_terms.append(base * ni - discounted)
|
||||||
|
steel_spent = sum(cost_terms) if cost_terms else 0
|
||||||
if cost_terms:
|
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).
|
# Airship launch: -7 steel, +3 asset renown (added to renown total).
|
||||||
launch = self.act[(ci, t, Action.LAUNCH)]
|
launch = self.act[(ci, t, Action.LAUNCH)]
|
||||||
self._add_delta("steel", t, -AIRSHIP_COST_STEEL * launch)
|
self._add_delta("steel", t, -AIRSHIP_COST_STEEL * launch)
|
||||||
self.launches.append(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 --------------------------
|
# --- Overwork "no collect next turn" lock --------------------------
|
||||||
for t in range(T - 1):
|
for t in range(T - 1):
|
||||||
|
|
@ -606,26 +791,39 @@ class _Builder:
|
||||||
# --- Final renown of the city --------------------------------------
|
# --- Final renown of the city --------------------------------------
|
||||||
self._build_city_renown(ci, city, installed, type_active)
|
self._build_city_renown(ci, city, installed, type_active)
|
||||||
|
|
||||||
def _free_upgrade_bool(self, ci: int, t: int):
|
def _gov_attr_bool(self, ci: int, t: int, pred, tag: str = "gov"):
|
||||||
agents = self.p.agents
|
"""Bool that is 1 iff a Governor of City ``ci`` on Turn ``t`` satisfies
|
||||||
contrib = []
|
``pred(agent)``. Returns None when no such Agent could govern here.
|
||||||
for ai, a in enumerate(agents):
|
|
||||||
if a.free_upgrade and (ai, ci, t) in self.gov:
|
Each City has at most one Governor per Turn, so the sum is itself a
|
||||||
contrib.append(self.gov[(ai, ci, t)])
|
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:
|
if not contrib:
|
||||||
return None
|
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))
|
self.m.Add(b == sum(contrib))
|
||||||
return b
|
return b
|
||||||
|
|
||||||
def _bonus_tg_expr(self, ci: int, t: int):
|
def _free_upgrade_bool(self, ci: int, t: int):
|
||||||
agents = self.p.agents
|
return self._gov_attr_bool(ci, t, lambda a: a.free_upgrade, "freeup")
|
||||||
terms = []
|
|
||||||
for ai, a in enumerate(agents):
|
def _bonus_collect_expr(self, ci: int, t: int, attr: str):
|
||||||
if a.bonus_trade_goods and (ai, ci, t) in self.gov:
|
"""Sum of ``agent.<attr>`` over Agents governing City ``ci`` on Turn
|
||||||
terms.append(a.bonus_trade_goods * self.gov[(ai, ci, t)])
|
``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
|
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):
|
def _build_collection(self, ci, city, installed, type_active):
|
||||||
"""Add per-turn resource deltas from Collection, for each possible type."""
|
"""Add per-turn resource deltas from Collection, for each possible type."""
|
||||||
m = self.m
|
m = self.m
|
||||||
|
|
@ -690,12 +888,22 @@ class _Builder:
|
||||||
exp_total = exp_base + self._mul_bool(exp_base, ow, self.MAXR)
|
exp_total = exp_base + self._mul_bool(exp_base, ow, self.MAXR)
|
||||||
self._add_delta("express", t, exp_total)
|
self._add_delta("express", t, exp_total)
|
||||||
|
|
||||||
# ---- bonus trade goods from governor agents (e.g. Baron) -------
|
# ---- per-Collection bonuses from governor agents ---------------
|
||||||
# Baron grants +N Trade Goods on Collection (N = bonus per Bastion).
|
# Each is applied whenever the City Collects (any type) while
|
||||||
# Applied whenever the city Collects (any type) while governed.
|
# governed. A bonus Governor occupies the City's only Governor slot,
|
||||||
btg = self._bonus_tg_expr(ci, t)
|
# so these never co-occur with the Planner's Overwork doubling.
|
||||||
if btg is not None:
|
# Baron -> +N Trade Goods (N = bonus per Bastion)
|
||||||
self._add_delta("trade_goods", t, self._gate_expr(btg, collect))
|
# 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
|
# store detail
|
||||||
self._collect_detail[(ci, t)] = dict(
|
self._collect_detail[(ci, t)] = dict(
|
||||||
|
|
@ -732,6 +940,15 @@ class _Builder:
|
||||||
ow = self.overwork[(ci, t)]
|
ow = self.overwork[(ci, t)]
|
||||||
harv = installed["harvester"][t]
|
harv = installed["harvester"][t]
|
||||||
overflow = installed["overflow_vats"][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_active = type_active[CityType.FOUNDRY][t]
|
||||||
found_collect = self._mul_bool(collect, found_active, 1)
|
found_collect = self._mul_bool(collect, found_active, 1)
|
||||||
# choose which vat to collect (at most one, only if found_collect)
|
# 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(r, t, c) # +1 target resource
|
||||||
self._add_delta("trade_goods", t, -c) # -1 Trade Good
|
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):
|
def _build_resource_balance(self):
|
||||||
m = self.m
|
m = self.m
|
||||||
T = self.T
|
T = self.T
|
||||||
|
|
@ -840,60 +1090,129 @@ class _Builder:
|
||||||
m.Add(v == prev + sum(deltas))
|
m.Add(v == prev + sum(deltas))
|
||||||
# resources must never go negative (enforced by domain >= 0).
|
# 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
|
m = self.m
|
||||||
T = self.T
|
T = self.T
|
||||||
obj = self.p.objective
|
ncities = len(self.p.cities)
|
||||||
terms = []
|
# 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
|
# Each Faction can Launch at most 3 Airships total (incl. any already
|
||||||
# launched before the planning horizon).
|
# launched before the planning horizon).
|
||||||
if self.launches:
|
if self.launches:
|
||||||
m.Add(sum(self.launches) <= AIRSHIP_MAX - self.p.airships_launched)
|
m.Add(sum(self.launches) <= AIRSHIP_MAX - self.p.airships_launched)
|
||||||
|
# Renown total = sum of city final renown + extra + launched airships.
|
||||||
# Renown total = sum of city final renown + extra.
|
|
||||||
renown_total = m.NewIntVar(0, 100000, "renown_total")
|
renown_total = m.NewIntVar(0, 100000, "renown_total")
|
||||||
launch_renown = AIRSHIP_RENOWN * sum(self.launches) if self.launches else 0
|
launch_renown = AIRSHIP_RENOWN * sum(self.launches) if self.launches else 0
|
||||||
m.Add(renown_total
|
m.Add(renown_total
|
||||||
== sum(self.city_final_renown) + self.p.extra_renown + launch_renown)
|
== sum(self.city_final_renown) + self.p.extra_renown + launch_renown)
|
||||||
self.renown_total = renown_total
|
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),
|
# 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.
|
# 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}
|
# Electrum uses its effective amount (incl. the Provisioner bonus).
|
||||||
self.final_amt = final_amt
|
self.final_amt = {r: self._resource_at(r, None) for r in RESOURCES}
|
||||||
|
|
||||||
def scaled(x: float) -> int:
|
def scaled(x: float) -> int:
|
||||||
return int(round(x * OBJ_SCALE))
|
return int(round(x * OBJ_SCALE))
|
||||||
|
|
||||||
if obj.mode == "linear":
|
# Every scoring term is a (turn, resource, scalar, log_mapping) tuple;
|
||||||
for r in RESOURCES:
|
# legacy mode/scalars are lowered into final-Turn terms by all_terms().
|
||||||
s = obj.scalars.get(r, 0.0)
|
for term in obj.all_terms():
|
||||||
if s:
|
if not term.scalar:
|
||||||
terms.append(scaled(s) * final_amt[r])
|
continue
|
||||||
s = obj.scalars.get("renown", 0.0)
|
amt_var = self._resource_at(term.resource, term.turn)
|
||||||
if s:
|
if term.log_mapping is None:
|
||||||
terms.append(scaled(s) * renown_total)
|
terms.append(scaled(term.scalar) * amt_var)
|
||||||
elif obj.mode == "log":
|
else:
|
||||||
for key, amt_var in (
|
table = term.log_mapping
|
||||||
[(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.
|
# value = table[min(amt, len-1)]; scale table values to ints.
|
||||||
vals = [scaled(v) for v in table]
|
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)])
|
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)
|
m.AddElement(idx, vals, val)
|
||||||
terms.append(int(round(s)) * val)
|
terms.append(int(round(term.scalar)) * val)
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown objective mode: {obj.mode}")
|
|
||||||
|
|
||||||
m.Maximize(sum(terms))
|
m.Maximize(sum(terms))
|
||||||
|
|
||||||
|
|
@ -932,7 +1251,12 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
|
||||||
if solver.Value(v) == 1:
|
if solver.Value(v) == 1:
|
||||||
chosen = a
|
chosen = a
|
||||||
break
|
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
|
continue
|
||||||
detail = ""
|
detail = ""
|
||||||
governor = ""
|
governor = ""
|
||||||
|
|
@ -965,8 +1289,17 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
|
||||||
detail = f"renovate -> {ct}"
|
detail = f"renovate -> {ct}"
|
||||||
elif chosen == Action.LAUNCH:
|
elif chosen == Action.LAUNCH:
|
||||||
detail = "launch airship"
|
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(
|
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,
|
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)
|
amt = solver.Value(var)
|
||||||
if amt:
|
if amt:
|
||||||
conversions.append({"turn": t, "resource": r, "amount": 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(
|
return Solution(
|
||||||
status=status_name,
|
status=status_name,
|
||||||
objective_value=solver.ObjectiveValue() / OBJ_SCALE,
|
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)),
|
final_renown_total=int(solver.Value(b.renown_total)),
|
||||||
plan=sorted(plan, key=lambda p: (p.turn, p.city)),
|
plan=sorted(plan, key=lambda p: (p.turn, p.city)),
|
||||||
trade_conversions=conversions,
|
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:
|
if "forced_city" in a:
|
||||||
a["forced_city"] = {int(k): v for k, v in a["forced_city"].items()}
|
a["forced_city"] = {int(k): v for k, v in a["forced_city"].items()}
|
||||||
agents.append(Agent(**a))
|
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()
|
kwargs = {k: v for k, v in d.items()
|
||||||
if k not in ("cities", "agents", "objective")}
|
if k not in ("cities", "agents", "objective")}
|
||||||
return Problem(cities=cities, agents=agents, objective=obj, **kwargs)
|
return Problem(cities=cities, agents=agents, objective=obj, **kwargs)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue