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
|
| * rather than whole cards means a second source of a color is credited for the double-pip | ||
| * costs a single source cannot pay, and a first source is credited for the progress it makes. | ||
| */ | ||
| private static int fixingNeed(final Player benefits, final int[] have, final int[] with) { |
There was a problem hiding this comment.
fixingNeed, depthValue, shortfall, progressOn
these are terrible method names
There was a problem hiding this comment.
Fair. Renamed so the family reads off getColorFixingValue:
shortfall→countMissingSources(cost, counts)progressOn→countSourcesFixed(cost, have, with)fixingNeed→countSourcesFixed(player, have, with)— the same question asked of one cost or of everything the player is holding, so it is the same name at two aritiesdepthValue→evaluateSpareSources(have, with)
Also cut the commentary around them back. It was running at about double the rate of the rest of forge-ai, and most of it belonged in the description.
| 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.
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>
49bc361 to
fe85a22
Compare
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).