Skip to content

AI: fetch the land colour it is actually short of - #11504

Open
liamiak wants to merge 8 commits into
Card-Forge:masterfrom
liamiak:ai-land-color-need
Open

AI: fetch the land colour it is actually short of#11504
liamiak wants to merge 8 commits into
Card-Forge:masterfrom
liamiak:ai-land-color-need

Conversation

@liamiak

@liamiak liamiak commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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; 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. Over 12 seeded AI-vs-AI games (deck 260613, three seeds) basicManaFixing fires 46 times, 44 with a real choice. One observed minType=Island decision offered:

Tundra(WU)  Underground Sea(UB)  Volcanic Island(UR)  Tropical Island(UG)
Raffine's Tower(WUB)  Ketria Triome(URG)  Breeding Pool(UG)  ...

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 towards BB, which a colour mask calls payable off a single one.

The parts are countMissingSources, countSourcesFixed and evaluateSpareSources.

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. getAvailableManaColors collected the raw Produced$ string, and every caller runs that through ColorSet.fromNames, which keeps only colour names — so Any was dropped entirely:

board, {W} in hand before after
3x City of Brass no colours WUBRG
3x Mana Confluence no colours WUBRG
3x Island U U

Checked against ComputerUtilMana.canPayManaCost as ground truth: before, the any-colour boards disagreed with it; after, every row agrees and the negatives stay negative. It now asks getProducibleColors, 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).

@tool4ever

Copy link
Copy Markdown
Contributor

might be some interesting ideas here but too messy

logic should be shared/consolidated around chooseBestLandToPlay
and if there's a difference needed for fetching vs. playing connect them with clear path

@liamiak

liamiak commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Consolidated around chooseBestLandToPlay as you suggested — the colour logic is in one place now and every caller ranks by the same number.

ComputerUtilCost.getManaSourceCounts is the shared primitive: sources of each colour a player could produce, optionally counting one more card. ComputerUtilCard.getColorFixingValue is the one number lands are ranked by. chooseBestLandToPlay, basicManaFixing and getBestLandAI all use it, so the two hand-rolled colour scans in AiController are gone — it's +3/-39 there. Only that one method and one constant are public; the generic pickStandout/bestBy helpers you reacted to are deleted.

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:

  • reading the produced-mana string missed "any colour" lands — Mana Confluence scored below a basic Plains
  • a colour mask couldn't tell one source of a colour from two, so BB looked payable off a single Swamp
  • getAllSpellAbilities also handed back a permanent's own casting cost and the far face of an MDFC or Adventure, so a resolved Bonecrusher Giant made a Mountain look needed

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. A 12-game seeded mirror sim finished 6-6 with no exceptions.

Known limits: a multi-colour source counts once per colour though it makes one mana; COLOR_FIXING_WEIGHT is picked to match the existing scale rather than tuned against deck costs; and Command Tower measured 0, but only because the test harness has no commander for ColorIdentity to resolve against — it goes through canProduce, which handles Combo ColorIdentity, so it should be right in an actual game.

Comment thread forge-ai/src/main/java/forge/ai/ComputerUtilCost.java Outdated
Comment thread forge-ai/src/main/java/forge/ai/ComputerUtilCost.java Outdated
Comment thread forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java Outdated
Comment thread forge-ai/src/main/java/forge/ai/ComputerUtilCard.java Outdated
Comment thread forge-ai/src/main/java/forge/ai/ComputerUtilCost.java Outdated
@liamiak

liamiak commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for taking a look at it!

All five, in one push — they turned out to share a cause.

MagicColor.Color.values() is exactly WUBRGC in that order, so it replaces the hand-written array outright and its ordinal() indexes the counts. I moved shortfall onto the same enum too, so the ordering has one source of truth instead of two that happened to agree.

getProducibleManaColors is deleted. It had no callers left once counts replaced it, and reading getOrigProduced was the less accurate check — that's what made it miss "any colour" lands. getAvailableManaColors goes back to exactly what it was.

On canProduceColorMana — I went one level down and used what it's built on. canProduceSameManaTypeWith already walked the mana abilities collecting colours via CardUtil.canProduce, and handled ManaReflected; I extracted that as Card.getProducibleColors so both callers share it.

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 basicManaFixing now scores each candidate once, keeping the best as it goes, instead of finding the maximum and filtering by recomputing it.

One thing worth flagging from doing this. Those canProduce calls are far more expensive when the ability has no activating player set — on a 40-permanent board the scan is 30us with one and 1191us without. So getProducibleColors fills it in, the way getMaxManaProduced already does. My first version set it unconditionally, which was wrong: ComputerUtilMana assigns a payer to mana abilities while it works out a payment, and overwriting that mid-simulation would have replaced state the simulation was relying on. It now only fills the field in when it's empty, so a payment in progress keeps its own. That's also the faster of the two, since abilities keep whatever was already set.

getColorFixingValue is 58us on that board now, against 86us before the review. 357 tests, 0 failures.

* 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixingNeed, depthValue, shortfall, progressOn
these are terrible method names

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. Renamed so the family reads off getColorFixingValue:

  • shortfallcountMissingSources(cost, counts)
  • progressOncountSourcesFixed(cost, have, with)
  • fixingNeedcountSourcesFixed(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 arities
  • depthValueevaluateSpareSources(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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this loop offer early exit in case colors is full?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

liamiak1 and others added 8 commits August 6, 2026 19:50
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>
@liamiak
liamiak force-pushed the ai-land-color-need branch from 49bc361 to fe85a22 Compare August 7, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants