Skip to content

AI: make the untap-for-ramp logic fire, and point it at the mana source - #11373

Open
liamiak wants to merge 6 commits into
Card-Forge:masterfrom
liamiak:improve-untap-ai
Open

AI: make the untap-for-ramp logic fire, and point it at the mana source#11373
liamiak wants to merge 6 commits into
Card-Forge:masterfrom
liamiak:improve-untap-ai

Conversation

@liamiak

@liamiak liamiak commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

AILogic$ PoolExtraMana is meant to let the AI tap a Voyaging Satyr to untap a land and reach a
bigger spell. Two things stop it working.

The gate counts the wrong mana. doPoolExtraManaLogic asks hasEnoughManaSourcesToCast, which
counts potential sources including tapped ones, and then asks whether the cost minus the freed mana
is payable using only sources it can actually use. Two notions of available mana in one test, so the
case the card exists for — plenty of lands, tapped out, untapping one reaches the spell — is exactly
the case it declines. Measured, Voyaging Satyr with a {2}{G} in hand:

board before after
2 untapped, 0 tapped untaps a Forest same
2 untapped, 1 tapped nothing untaps a Forest
2 untapped, 2 tapped nothing untaps a Forest
1 untapped, 2 tapped nothing nothing
3 untapped, 0 tapped casts it same

The decision never reaches the target chooser. untapPrefTargeting falls through to
getMostExpensivePermanentAI. On a land-only untapper you can't tell, because every legal target is
a land; on one that untaps any permanent it takes the biggest creature instead of the land. It now
asks getBestLandAI when the reason is mana.

With both fixed the same AILogic covers cards that untap any permanent, so this drops
AI:RemoveDeck:All from Kiora's Follower, Unbender Tine, Vizier of Tumbling Sands, Tidewater Minion
and Krosan Restorer, and adds the logic to Argothian Elder and Ley Weaver, which are unflagged and
untap two lands today whether or not it helps. Aphetto Alchemist stays flagged: it untaps artifacts
and creatures, never lands.

The opponent's-turn clause untapped unconditionally. It now checks that we hold something castable
at that timing, which is what its own comment always said it was for.

Cost. The gate swap makes one of these cards' main-2 decision about 22% dearer — 10.6ms to
12.9ms for a whole chooseSpellAbilityToPlay on a 40-permanent board, because canPayManaCost is
1.6ms against hasEnoughManaSourcesToCast at 1.1ms. The opponent's-turn check is deliberately kept
off the mana solver for the same reason: 4.1ms against 4.2ms before. One board, one machine.

Four tests, three of which fail without the change. Krosan Restorer's threshold ability and Tidewater
Minion's {4} Debuff were checked separately, since the flag was suppressing those too. Vizier's
cycling trigger I reasoned about rather than measured: it is an untargeted free untap, so it should
not be a loss.

🤖 Implemented with the assistance of Claude Code (Opus).

Comment thread forge-ai/src/main/java/forge/ai/ability/UntapAi.java Outdated
liamiak1 and others added 3 commits August 7, 2026 16:33
doPoolExtraManaLogic gated on hasEnoughManaSourcesToCast, which counts
potential mana sources including tapped ones, then asked whether the cost
minus the freed mana was payable using only usable sources. Two notions of
available mana in one test, so the case the logic exists for - plenty of
lands, tapped out, untapping one reaches the spell - was the one it declined.

untapPrefTargeting then fell through to getMostExpensivePermanentAI, so even
once the decision was right the target was chosen by mana value rather than
by the reason. It now asks getBestLandAI when the reason is mana.

The opponent's-turn clause untapped unconditionally; it now checks that we
are holding something castable at that timing, which is what its own comment
always said it was for. That check deliberately stays off the mana solver:
it runs on every step past declare blockers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the ramp logic working for cards that untap any permanent, the same
AILogic covers them, so AI:RemoveDeck:All comes off Kiora's Follower,
Unbender Tine, Vizier of Tumbling Sands, Tidewater Minion and Krosan
Restorer. That flag suppresses every ability of a card, not just the untap,
so Krosan Restorer's threshold ability and Tidewater Minion's Debuff were
checked separately - the threshold ability needed the logic too, or it fired
and untapped lands for nothing.

Argothian Elder and Ley Weaver were never flagged and untap two lands today
whether or not it helps; they get the logic for the same reason.

Aphetto Alchemist stays flagged: it untaps artifacts and creatures, never
lands, so the ramp channel does not reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the four fail without the preceding two commits: ramping when
tapped out but source rich, untapping the mana source rather than the
biggest tapped permanent, and holding on the opponent's turn with nothing
to hold up. The fourth guards the new gate against over-firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@liamiak liamiak changed the title AI: only untap a permanent when it makes a concrete difference AI: make the untap-for-ramp logic fire, and point it at the mana source Aug 7, 2026
PT:2/2
A:AB$ Untap | Cost$ T | ValidTgts$ Permanent.Other | TgtPrompt$ Select another target permanent. | SpellDescription$ Untap another target permanent.
AI:RemoveDeck:All
A:AB$ Untap | Cost$ T | ValidTgts$ Permanent.Other | TgtPrompt$ Select another target permanent. | AILogic$ PoolExtraMana | SpellDescription$ Untap another target permanent.

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.

not sure ignoring the other card types is better in general

also I don't really like how the logic just guesses AI wants to pay with the extra shard, I need to think if it makes sense to synchronize it with ComputerUtilMana somewhow

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.

You were right, and it's worse than it looked — thanks for pushing on it.

Two separate things were narrowing these cards.

"Mana source" was spelled "land". The target choice filtered on LANDS_PRODUCING_MANA, so a tapped Llanowar Elves or Signet was invisible even though reusing one is the same ramp. Now CardPredicates.PRODUCES_MANA with getBestAI, which detectPriorityUntapTargets a few lines up already uses for mixed lists.

The bigger one: the AILogic was vetoing untaps that are worth making anyway. checkAiLogic runs before checkApiLogic, so when doPoolExtraManaLogic declined, detectPriorityUntapTargets never ran at all. Measured: with AILogic$ PoolExtraMana a Kiora's Follower will not untap a tapped Time Vault; without the tag it untaps it and takes the extra turn. The gate is now additive — doPoolExtraManaLogic(...) || hasPriorityUntapTarget(...) — so mana is a reason to untap rather than the only one.

Timing note: my first version of that check cost about a fifth of whole chooseSpellAbilityToPlay on a wide board, because getTargetableCards ran over everything. Filtering on tapped first brings it back to the noise floor.

The eleven land-only carriers are unaffected — their ValidTgts re-imposes Land, and on an all-land list the new predicate and getBestAI select exactly what the old ones did.

On the shard guess: that arithmetic and its TODO are already on master, I only moved them — not defending it, and happy to leave it untouched while you think about the ComputerUtilMana side. One data point in case it helps: since it only shaves GENERIC, a {G} one-drop is never predicted; I had to use a two-drop to make the gate fire in a test.

@tool4ever tool4ever Aug 15, 2026

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.

Still undecided but some notes:

  • we would save the runtime cost of two extra payability checks per SA
    • since priority order for these pool abilities should be rather low most likely other SA costs have been checked before them in that priority window
      • might also make it easier to eventually remove the limit for permanent spells since we know AI considered the API playable (though some safety checks still run after canPayCost)
  • but also lose the knowledge that running checkApiLogic in isolation gives us a "fully aware" decision
    • then again it's not really a hard contract, might find a way to cover both
    • there is already some precedent of API checks mixing things via AiCardMemory
    • still need to compare with related ManaAi#doManaRitualLogic

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.

I went and did the doManaRitualLogic comparison — the premise splits between the two.

ManaRitual is evaluated after the hand: saComparator exempts ApiType.Mana from the "cast 0 mana cost spells first (might be a Mox)" hoist, so the AI has genuinely costed the hand first (measured order: Sengir Vampire, Nightmare, Bog Wraith, Hill Giant, then Dark Ritual).

The untappers are hoisted to the front of the window — a {T} ability has total mana 0 and isn't ApiType.Mana. Measured, the untap is the only SA evaluated, so there's no prior knowledge yet to reuse.

Prototyped both directions (25 permanents, 5-card hand, configs interleaved in one JVM):

(a) order + memory — add PoolExtraMana to that ApiType.Mana exception; record CantAfford/CantAffordX hosts in a new AiCardMemory set cleared next to HELD_MANA_SOURCES_FOR_NEXT_SPELL; the scan only narrows on it — the payability check still decides, and an empty set falls back to the full scan, so checkApiLogic in isolation still computes its own answer. Gate 3.8 → 1.0 ms, 10 → 3 solver calls (reachable); 3.3 → 1.7 ms, 10 → 5 (unreachable).

(b) prefilter — no ordering change: skip a spell before any solver call when cmc > getAvailableManaEstimate(ai, true) + extraGeneric, i.e. ManaRitual's arithmetic as a pre-filter for this exact check. Gate → 1.7 ms / 4 calls and 0.7 ms / 2 calls. Doesn't touch the isolation question at all; the cost is that the estimate is colour-blind and can under-count, so it can miss a ramp.

UntapAiTest passes 7/7 under either as default; full suite 362/0/6, same as baseline. Whole-phase timing I can't call — two runs disagreed on the sign.

Happy to land either separately from this PR, or to leave it entirely while you think about the ComputerUtilMana side. (b) is five lines and independent of everything you're weighing; (a) is the one that gives each logic the half it's missing.

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.

thanks, admittedly I did not expect saComparator to also move all 0 cmc abilities to the front 🤦‍♂️

I will check if that's really the best default behaviour

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.

notably it's a 14 year old change which includes more than its comment claims :/

  • getting a better option for an activated ability by casting a permanent first seems smart
  • but AI would then also prefer using mana for Assassinate even if King's Assassin is available too

so which order is more useful seems more like a highly situational 50/50 instead of an obvious heuristic?
though maybe the downside of wasting mana is too high 🤔

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.

Tested the ordering question rather than argue it, and it goes against my own option (a). Same board as rampsWhenTappedOutButSourceRich, but with both a Centaur Courser {2}{G} (needs the untap) and a Grizzly Bears {1}{G} (doesn't) in hand:

untapped? cast
today yes Centaur Courser
(a) untapper last no Grizzly Bears - Courser stranded
(b) prefilter yes Centaur Courser

Evaluated after the hand, the AI takes the affordable 2-drop and the untap never happens. So the hoist is doing real work for this logic - please drop (a). (b) is the five-liner that doesn't touch ordering at all. None of the seven UntapAiTest cases holds both an affordable and an unaffordable spell, which is why this never surfaced.

For the wider question: AIActivateLast$ True is checked in compareEvaluator above the 0-cost hoist, so cards that lose under the default can already opt out - only Vivien of the Arkbow uses it today. And the priority loop (PhaseHandler ~1049) keeps asking until the player passes, so activating a free ability first doesn't forfeit the spell; it's cast on the next pass.

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.

the problem with such tests is that they're pretty arbitrary:
if AI had another one drop available you could instead have support to go wide which is another viable strategy (more bodies to overwhelm opponent defense)

@tehdiplomat
your opinion on always using 0 cmc activated abilities first?

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.

Your #11642 looks like it answered this: the 0 cmc hoist stayed, the comment moved to "use 0 cmc abilities first (might be a Mox)" which is what it actually does, and going wide is now a TODO alongside it. I am reading that as settled unless you meant otherwise.

On that basis option (a) is dropped - the ordering change and the memory set with it - which is also where the tests you called arbitrary were pointing, so those go too. That leaves (b), the five-line prefilter that does not touch ordering at all.

liamiak1 and others added 3 commits August 9, 2026 19:05
The PoolExtraMana target choice filtered on LANDS_PRODUCING_MANA, so a
tapped mana creature or artifact was invisible to it even though reusing
one is the same ramp. Add CardPredicates.PRODUCES_MANA and pick with
getBestAI, which handles a mixed permanent list. The two battlefield
lookups in the gate were the same filter twice, so they now share a
helper.

Land-only carriers are unaffected: their ValidTgts re-imposes Land, and
on an all-land list the new predicate and getBestAI select what the old
ones did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
checkAiLogic runs before checkApiLogic, so when doPoolExtraManaLogic
declined, detectPriorityUntapTargets never ran at all. A Kiora's Follower
would not untap a tapped Time Vault; without the AILogic it untaps it and
takes the extra turn. Make the gate additive so mana is a reason to untap
rather than the only one.

Tapped-ness is checked before targetability: filtering the other way
round put getTargetableCards over the whole board and cost about a fifth
of chooseSpellAbilityToPlay on a wide one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One test per behaviour: reusing a mana creature when no land is in play,
freeing a Time Vault with no mana reason, and holding the tap when there
is no source to reuse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@liamiak

liamiak commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Separately, and independent of the ordering question above: there is a colour form of the reach check ready, which settles the TODO in untapReachesASpell.

Untapping a source hands back that source's colour, not a generic mana, so a spell held up on coloured pips never shows as reachable - {G}{G} has no generic part for decreaseShard to reduce. With one untapped and one tapped Forest and Strangleroot Geist in hand, the AI keeps its untapper and casts nothing. +42/-9 with a test that fails without it, and it costs 19 extra solver probes over a twelve turn game, so about 8ms.

I have not pushed it here, because it delegates to ComputerUtilCost.isPayableWith, which #11504 adds - the same helper that PR now uses to decide what a land would turn on. Pushing it would drag that PR's contents into this one. So it waits on #11504 landing, or I can inline a local copy here if you would rather this one did not wait.

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