AI: fetch the land colour it is actually short of - #11504
Conversation
|
might be some interesting ideas here but too messy logic should be shared/consolidated around |
|
Consolidated around
Fetching and playing differ in exactly two explicit things: fetching can be for another player, and the pool is a library rather than a hand. Building it that way turned up three bugs in my own first version, all fixed in this push and covered by tests:
Measured on a 40-permanent board: Known limits: a multi-colour source counts once per colour though it makes one mana; |
|
Thank you for taking a look at it! All five, in one push — they turned out to share a cause.
On The two scans are one: the candidate can only add to what's already on the battlefield, so the second set of counts is a clone plus that one card. And One thing worth flagging from doing this. Those
|
| if (ab.getApi() == ApiType.ManaReflected) { | ||
| colors.addAll(CardUtil.getReflectableManaColors(ab)); | ||
| } else { | ||
| colors = CardUtil.canProduce(6, ab, colors); |
There was a problem hiding this comment.
shouldn't this loop offer early exit in case colors is full?
There was a problem hiding this comment.
Added, and in the end in both places.
Within one card it is safe to break because canProduce(6, …) and getReflectableManaColors both draw from COLORS_AND_COLORLESS, so the set cannot exceed six. It earns very little there though — of 1,870 mana-producing cards in the pool, only Plaza of Heroes and White Lotus Hideout still have an ability left to walk once the set is full.
Across sources it is worth much more, but it needed a fix first. getAvailableManaColors was collecting the raw Produced$ string, so its set held Any and Combo ColorIdentity alongside W and there was no size at which it was full. Worse, since every caller runs it through ColorSet.fromNames, which keeps only colour names, an Any source was contributing nothing at all — three City of Brass read as no colours available, and canBePaidWithAvailable then disagreed with ComputerUtilMana.canPayManaCost about a plain {W}.
It now asks getProducibleColors, which resolves those and makes the set bounded, so the break there is both correct and fires on any five-colour board. Thanks for the nudge — I would not have looked at that method otherwise.
49bc361 to
fe85a22
Compare
fe85a22 to
1ff5144
Compare
tool4ever
left a comment
There was a problem hiding this comment.
After further consideration I feel like complicating the logic this way isn't the right path forward since including all the available mana costs still says nothing about if AI would even want to pay them :/
Therefore it could be highly situational if building the mana base simply around missing colors doesn't lead to a better outcome.
Additionally this overlaps with my same argument in #11373 (though arguably a wrong result here isn't as bad as an untap-ramp into nothing) so a clean approach would benefit both.
These PR were still helpful for letting me think about the bigger picture, even if it's sometimes painful to untangle the AI code :P
I might try to cherry pick some of the cleanup done here later...
Land searches picked by list order. basicManaFixing chose the basic type the player
had fewest of, then took list.get(0) from whatever survived that filter, and
getBestLandAI ended in Aggregates.random. Neither asked which colours were actually
blocking anything.
Every fetchland comes through here - areAllBasics("Plains,Island") is true - and a
"Plains" search matches every dual carrying the Plains type. Measured over 12 seeded
AI-vs-AI games (deck 260613, three seeds), basicManaFixing fires 46 times, 44 of them
with a real choice, once over 31 candidates. One observed decision offered Tundra,
Underground Sea, Volcanic Island, Tropical Island, Raffine's Tower and Ketria Triome
among others - all carrying Island, all different beside it - and it took Tundra
because Tundra was first.
getColorFixingNeed counts how many cards go from unpayable to payable if that player
had this land, across their hand and the activatable abilities on their permanents.
It reuses ComputerUtilCost.getAvailableManaColors, which already takes an "if I also
had this land" argument, and canBePaidWithAvailable.
Ordering turned out to matter more than the metric. Colour need is asked before the
basic-type count, because that count cannot tell a colour that is missing from one
that is merely uncommon: with three Islands and a hand wanting black it concluded it
needed Plains, having none, and fetched a Plains-Island. Asking first also means the
whole candidate list is still in front of it rather than the remains of a filter.
Where a measure cannot separate the candidates it returns null and the caller keeps
what it was already doing, so nothing decides while blind - evaluateLand is never
asked to rank a utility land against a basic, and the existing fallbacks stand.
Of the 44 real decisions, colour need has signal in 33 and changes the pick in 25.
Also fixes what the comment above the old call site suspected: basicManaFixing read
the decider's board while searching someone else's library. It now works from the
owner's side, and inverts every layer when an opponent is the one choosing. That
inversion is covered by a unit test but never ran in the measured games - deck 260613
has no Chooser$ cards - so it is the least exercised part of this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the decision directly and through a real Flooded Strand activation, which is how it is reached in a game: two duals both carrying the searched-for type, only one of which unblocks the hand. Also pins the two ways it declines to act - an opponent choosing gives the least useful land, and identical candidates leave the caller's own ordering alone. The fetchland case fails without the fix. Drop this commit if you would rather not carry the tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reworked after review. The colour logic now lives in one place and every caller ranks by the same number, rather than the fetch path carrying a second implementation beside the one in chooseBestLandToPlay. ComputerUtilCost.getManaSourceCounts is the shared primitive: how many sources of each colour a player could produce, optionally counting one more card. It uses canProduce rather than reading the produced-mana string, so "any colour" and choice-of-colour lands are counted - Mana Confluence previously scored below a basic Plains. ComputerUtilCard.getColorFixingValue is the single number lands are ranked by, combining what the land lets us pay for with the depth it adds in colours we are thin on. chooseBestLandToPlay, basicManaFixing and getBestLandAI all use it, so its two hand-rolled colour scans are gone (+3/-39 there). Demand is counted in pips, not whole cards: a colour mask cannot tell one source of a colour from two, so it thought BB was payable off a single Swamp. Counting the shortfall also credits a first source for the progress it makes rather than only the source that completes a cost. Two things the mask version got wrong, both now covered by tests: counts come from what the board can produce rather than what is untapped, so holding a land for main 2 does not change the answer; and only activated abilities on permanents count, since getAllSpellAbilities also returns a permanent's own casting cost and the far face of an MDFC or Adventure. Measured on a 40-permanent board: getColorFixingValue is 86us, so a land drop with eight candidates costs 0.69ms once per turn, and the shared scan is cheaper than the getAvailableManaColors call it partly replaces. 12-game seeded mirror sim finished 6-6 with no exceptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things from the review, all in one pass since they turned out to share a
cause.
The hand written {"W","U","B","R","G","C"} is gone; MagicColor.Color.values()
is already exactly that, in that order, and its ordinal indexes the counts.
shortfall now uses the same enum rather than ManaAtom, so there is one source
of truth for the ordering instead of two that happened to agree.
getProducibleManaColors is deleted. It had no callers left once the counts
replaced it, and reading getOrigProduced was the less accurate check anyway -
it is what missed "any colour" lands. getAvailableManaColors goes back to
exactly what it was.
Colour collection now goes through Card.getProducibleColors, extracted from
canProduceSameManaTypeWith, which already walked the mana abilities this way
using CardUtil.canProduce and handled ManaReflected. Both callers share it.
getColorFixingValue makes one pass over the battlefield instead of two: the
candidate can only add to what is already there, so the second set of counts
is a clone plus that one card. basicManaFixing scores each candidate once and
keeps the best as it goes, rather than finding the maximum and then filtering
by recomputing it.
One thing worth recording: getProducibleColors sets the activating player on
each mana ability first, as getMaxManaProduced already does. Without it every
canProduce falls into a far more expensive path - measured on a 40 permanent
board, the scan is 30us with it and 1191us without. Same board as before, so
getColorFixingValue stays around 86us and a land drop with eight candidates
under a millisecond.
357 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ComputerUtilMana sets a payer on mana abilities during payment simulation, and that payer is not always the card's controller. getProducibleColors was overwriting it unconditionally, so a call landing mid-simulation could clobber the state that simulation was relying on. Filling it in only when null keeps the cheap path for cold abilities without touching a payment in progress. Faster too, since abilities keep whatever was already set: getColorFixingValue is 58us on the same 40 permanent board, against 86us before this review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
countMissingSources / countSourcesFixed / evaluateSpareSources, so the family reads off getColorFixingValue. Stop getProducibleColors once every colour is present, and trim the commentary back towards the rate the rest of forge-ai runs at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It passed off three Islands and one Swamp even with the pip counting removed, because the depth term alone separated the two candidates. One of each leaves the pip counting as the only thing that can. Also rank through getBestLandAI, which nothing exercised with a real player. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getAvailableManaColors collected the raw Produced$ string, and every caller runs that through ColorSet.fromNames, which keeps only colour names - so "Any" contributed nothing and a board of City of Brass read as unable to cast anything coloured. Ask getProducibleColors instead, which resolves it, and stop once every colour is present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getProducibleColors filled in a missing activating player and left it set. canProduceSameManaTypeWith, which this method was extracted from, had no such side effect before, so the extraction was quietly adding a write to a rules engine read - and setActivatingPlayer trickles down to sub-abilities and additional ability lists, so it reached further than the one ability. It is now set for the duration of the check and put back in a finally. The fill in exists only because canProduce falls into a much more expensive path without a player, and that path is only taken during the call, so nothing is lost: getColorFixingValue measures 26us per call over a 39 permanent board with the restore against 28us leaving it set, 20000 calls after warmup. Worth noting the loop this PR removes from AiController set the activating player unconditionally, so this path no longer mutates at all where it used to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Counting sources answers how much closer a land brings the coloured
requirements, and it already counts pips properly - {B}{B} off one Swamp
is one short, not satisfied. What it cannot answer is whether the land
lets anything be cast this turn, mana and all, which is the stronger
reason to play one. With one Forest out and Doom Blade {1}{B} and Angel
of Mercy {4}{W} in hand, both a Swamp and a Plains fix exactly one
missing colour and the counting rates them identically; only the Swamp
actually casts anything.
ComputerUtilCost.isPayableWith asks that. An untapped land entering play
is one more mana of a colour, so it is modelled by paying one pip with it
up front, the way convoke pays one, and putting what is left to the mana
solver - nothing is moved and the answer is the engine's own. It also
takes extra generic mana, because UntapAi asks the same question about
reusing a tapped source and its TODO there asks for the colour form.
Cached per colour rather than per candidate, since every land producing a
colour answers the same, and cleared with AiCache each priority. A reach
check runs ahead of the solver: one land is one mana, so anything further
out cannot turn on this turn whatever its colours.
Measured end to end, seeded and in one JVM, this costs 9-15% of a fast
game. It is confined to land decisions - chooseBestLandToPlay,
getBestLandAI and basicManaFixing - and never runs during ordinary
priority evaluation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1ff5144 to
d4bc44e
Compare
|
Rebased onto current master, and pushed a change that I hope answers the objection rather than restating the PR. You were right that counting what the board can produce says nothing about whether the AI would want to pay it. What the counting also cannot say is whether a land lets anything be cast this turn. With one Forest out and Doom Blade
It also takes extra generic mana, because +210 lines over the previous state, four files. Confined to land decisions — Worth being straight about the other half of that: I can measure what this costs but not what it wins. Seeded AI-vs-AI games measure noise at about the same magnitude, so whether better land choice is worth it is not something this harness can settle. The cleanup you mentioned cherry-picking is still here and still independent of all of this. |
Land searches picked by list order.
basicManaFixingchose the basic type the player had fewest of, then tooklist.get(0)from whatever survived that filter;getBestLandAIended inAggregates.random. Neither asked which colours were actually blocking anything.Every fetchland comes through here —
areAllBasics("Plains,Island")is true — and a "Plains" search matches every dual carrying the Plains type. Over 12 seeded AI-vs-AI games (deck 260613, three seeds)basicManaFixingfires 46 times, 44 with a real choice. One observedminType=Islanddecision offered:All carry Island; all differ beside it. It took Tundra because Tundra was first.
The measure
ComputerUtilCard.getColorFixingValue(player, land)is the single number every caller ranks by: how many missing colour sources that land supplies across the player's hand and the activatable abilities on their permanents, plus depth in the colours they are thin on. Counting sources rather than colours is what credits a second Swamp towardsBB, which a colour mask calls payable off a single one.The parts are
countMissingSources,countSourcesFixedandevaluateSpareSources.Colour need is asked before the basic-type count, because that count cannot tell a colour that is missing from one that is merely uncommon: with three Islands and a hand wanting black it concluded it needed Plains — having none — and fetched a Plains-Island.
An "Any" source counted for nothing
Found while answering review here, and folded in because it is the same code path.
getAvailableManaColorscollected the rawProduced$string, and every caller runs that throughColorSet.fromNames, which keeps only colour names — soAnywas dropped entirely:{W}in handChecked against
ComputerUtilMana.canPayManaCostas ground truth: before, the any-colour boards disagreed with it; after, every row agrees and the negatives stay negative. It now asksgetProducibleColors, which resolves the colours and makes the set bounded, so it can also stop once every colour is present.Testing
mvn -pl forge-gui-desktop -am test: 358 tests, 0 failures.Seven tests, sized by mutation rather than by count: removing the depth term, its diminishing returns, the hand scan, the permanent-cost exclusion, the per-colour pip counting, the search narrowing, the tapped-source invariance or the any-colour fix each turns at least one of them red.
One caveat on the numbers above: the 46/44 counts describe the old behaviour and still stand, but the share of picks that change was measured against the first version of the metric and has not been re-run since the scoring changed.
🤖 Implemented with the assistance of Claude Code (Opus 5).