Skip to content

Let the AI count converge before it decides whether to cast - #11486

Draft
liamiak wants to merge 11 commits into
Card-Forge:masterfrom
liamiak:ai-converge-prediction
Draft

Let the AI count converge before it decides whether to cast#11486
liamiak wants to merge 11 commits into
Card-Forge:masterfrom
liamiak:ai-converge-prediction

Conversation

@liamiak

@liamiak liamiak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Rebased onto master. #11515 took the PermanentAi piece, so that is out of this branch — what
remains is the prediction and the paths the loop cannot reach.

Count$Converge reads 0 while deciding. Nothing has been spent yet and getCastSA() is null, so
Bring to Light searched for a 0-drop and never cast. getPayingColors() now asks the activating
player's controller what it expects to spend: PlayerController answers 0, so humans are unchanged,
and PlayerControllerAi forwards to what AiController worked out. Nothing is parked on the spell.

Storing it. The prediction lives on AiController, keyed by spell identity, reset at priority
next to AiCache.clear() — the same species of state as HELD_MANA_SOURCES_FOR_NEXT_SPELL four
lines up. canPlayAndPayFor snapshots and restores it, so a nested evaluation cannot lose the outer
spell's answer. Keeping the announced X alongside the colours also settles the recompute you
flagged: the same spell reaches setMaxXValue once from the prediction and again from its own API
logic, and each walk solves the payment at every X. Chamber Sentry off five lands, 12 payment solves
→ 6; off nine lands, with a five-colour early exit, 20 → 6.

Sweep the Skies is the case the loop does not reach. TokenAmount$ Y reads Count$Converge off
a different SVar than X announces, so neither the per-API override nor PermanentAi ever sized it —
one thopter on master, five here.

The four Count$Converge overrides are gone. Each worked the amount out with calculateAmount
and then overrode it, because calculateAmount returned zero before anything had been paid. With
the prediction the engine's own expression is right, and getConvergeCount has no callers left.

Then it tutored for the worst card. chooseCardToHiddenOriginChangeZone treats
Destination$ Exile as dumping junk, so on your own library it took getWorstAI — Bring to Light
picked a Runeclaw Bear over a Serra Angel. Exile is a staging step before DB$ Play. Eight
unflagged cards share this. Scoped by calling the chooser across every destination × origin ×
own-or-opponent: one cell of thirty moves.

Engineered Explosives stays flagged — its counters select which mana value the wipe hits, so most
colours is usually the worst live choice.

355 tests, 0 failures.

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

final byte colors = ComputerUtilMana.getConvergeColors(sa, player);
for (byte color : MagicColor.WUBRG) {
if ((colors & color) != 0) {
sa.getPayingMana().add(new Mana(color, host, null, player));

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.

don't like having to create extra fake Mana

what are the alternatives?

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're right — I missed this one on the first pass. Working it now, expect another push.

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.

Pushed. Went with predicting the colours rather than the payment: a byte predictedPayingColors on SpellAbility that getPayingColors() ORs in, set and cleared on the same lines that already borrow and return castSA.

payingMana is a multiset with provenance and the prediction is only a set of colours, so the Mana objects were right about colour and invented the rest — one entry per colour, each sourced from the spell itself, which is what Count$Adamant, Count$TotalManaSpent and Count$EachSpentToCast read.

On the redundancy point: getSunburst() had no callers left once getConvergeCount was rewritten — it was ColorSet.fromMask(sunburstMap).countColors() over the same field getColorsPaid() returns — so I removed it. Happy to put it back if you'd rather keep the accessor.

To be straight about the scope: the mask is only set for hasConverge() cards, and none of the 31 in the pool use ManaColorsPaid or ManaSpent, so Count$Converge is the only reader that can observe a non-zero value today. Outside the AI's own evaluation getPayingColors() is unchanged.

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.

Mhn, I'll think about it though a lot of work has previously gone into removing AI only stuff from the main rules engines...

/**
* Return the colors that would be used for payment, as a color mask.
*/
public static byte getConvergeColors(final SpellAbility sa, final Player ai) {

@tool4ever tool4ever Aug 2, 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.

don't mind a more generic solution but then you need to replace the specific variant above
otherwise we end up with duplicated logic that also wastes runtime
though the ones with X will probably make things tricky 🤔

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.

Agreed, and that's done as of the push after your review — getConvergeCount is now just ColorSet.fromMask(getConvergeColors(sa, ai)).countColors(), so there's one payment simulation rather than two. Same result either way: getSunburst() was already ColorSet.fromMask(sunburstMap).countColors() and getColorsPaid() returns that same mask.

The X ones are in that push too — X gets announced before the colours are measured, since on those cards X is what buys them.

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.

well the code duplication is gone but the method is still used and calculates again for no reason?

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're right, and it goes further than that — the method can go entirely.

All four callers had the same shape: work out the amount with calculateAmount, then override it for Count$Converge. That override exists because calculateAmount returned zero before anything had been paid, which is the problem this branch set out to fix. With the prediction in place the engine's own expression gives the right number, so the override was running a full payment simulation to reach a value the line above it already held.

I checked rather than assumed, since it would be easy to see the right number coming from resolution instead of evaluation. Instrumenting the Count$Converge branch in AbilityUtils and tagging each call with its caller: 21 of them arrive via ChangeZoneAi.hiddenOriginCanPlayAI — the AI deciding whether to cast — with castSA set and the count correct.

PermanentAi's converge loop was already replaced by setXForBestConverge earlier on this branch, so removing these four leaves getConvergeCount with no callers and it goes too. That commit is +1/-20.

Verified on the paths I could reach. Painful Truths on the DrawAi path and Unified Front on TokenAi both get cast and sized correctly with the override gone — three cards and three life, four tokens. I added the first as a regression test. DamageAllAi I could not exercise: its checkApiLogic is never reached for Radiant Flames, the only DamageAll converge card in the pool. It is the same one-line pattern as the other two so I would not expect it to differ, but it is untested here rather than verified.

339 tests, 0 failures.

@liamiak

liamiak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed two commits.

reuse the converge colours the X search already found — the recompute you spotted. setXForBestConverge now returns the mask it settled on instead of the caller working it out again: 13 → 12 payment simulations casting Chamber Sentry off five lands.

offer the converge prediction through PlayerController instead — an alternative for your point about AI-only state in the rules engine. Not meant to land alongside the field version; take whichever you prefer and I'll drop the other.

It parks nothing on the spell. getPayingColors() asks the activating player's controller what it means to spend while nothing has been spent; PlayerController answers 0 so humans are unchanged, and PlayerControllerAi forwards to what AiController worked out. One seam still serves Converge, Count$ManaColorsPaid and both ManaSpent checks, and there's no mask left to go stale.

Scoped twice — canPlayAndPayFor hands back whatever the outer spell was expecting, so a nested evaluation can't lose the outer prediction, and chooseSpellAbilityToPlay resets it next to AiCache.clear() and predictedCombat. HELD_MANA_SOURCES_FOR_NEXT_SPELL four lines up is the same species of state: good for one spell, kept AI side, reset at priority.

The honest trade: the field version is +17/−21 across three files, this is +60/−17 across six, and the prediction becomes controller-scoped rather than spell-scoped, so a runWithController swap loses it. Both cases degrade to 0, which is the answer before any of this.

@tool4ever

tool4ever commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

my recompute was still meant for something else but I'll try to explain if I can also figure out good prediction storing to revisit it

I think for now I'll just cherry pick the fix in PermanentAi while keeping the early loop exit around

@tool4ever
tool4ever marked this pull request as draft August 3, 2026 18:34
liamiak1 and others added 11 commits August 14, 2026 08:49
On a converge or sunburst card X has one job: buy colours. Nothing announced
it usefully, so Chamber Sentry was never cast at all (at X=0 it is a free 0/0
that dies), Skyrider Elf arrived as a 2/2 instead of a 5/5, and Sweep the
Skies made one thopter instead of five.

PermanentAi measured its converge baseline at the maximum X that setMaxXValue
leaves behind, so the first step of its walk always compared worse and X
collapsed to 0. Rather than fix that loop in place it is deleted: setMaxXValue
is where every X announcement already goes, so the choice belongs there. It
still returns the largest affordable X, so the xPay <= 0 checks at its 66 call
sites are unchanged - only the announced value differs, and only for a spell,
since converge counts colours spent casting.

getConvergeCount now derives from getConvergeColors instead of running its own
copy of the payment simulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Count$Converge reads the colours actually spent, which nothing has while the
AI is still deciding, so every converge effect was evaluated as if it were
empty. On Bring to Light that means ChangeType$ Creature.cmcLEX with X=0 - it
searches for a 0-drop, finds nothing, and never casts.

canPlayAndPayFor already lends the card its castSA for this same reason, so
lend it the payment it is about to make too, announcing X first because that
is what buys the colours being measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chooseCardToHiddenOriginChangeZone treats Destination$ Exile as "exiling or
bouncing stuff", so when the library being searched is the AI's own it picks
getWorstAI. That is right for exile-as-removal, but a tutor exiles as a
staging step before a DB$ Play casts the card - Bring to Light deliberately
searched out the worst creature it could legally find.

Nine cards are Library -> Exile with a Play sub-ability and eight of them are
unflagged, so this is live: Beseech the Mirror, Emergent Ultimatum, Evolving
Door, Jace Architect of Thought, Kasmina Enigma Sage, Portent of Calamity,
The Heron Moon, Djinn of Wishes.

Searching an opponent's library still takes their best card - that branch is
untouched. Unflags Bring to Light.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of the three fails without its own commit: Chamber Sentry is not cast,
Bring to Light is not cast, and Bring to Light takes a Runeclaw Bear over the
Serra Angel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prediction was stored as Mana objects pushed onto payingMana, which is a
multiset with provenance - so it was honest about colour and invented the
other two dimensions: one entry per colour, each sourced from the spell
itself. Count$Adamant, Count$TotalManaSpent and Count$EachSpentToCast all
read those.

Store a colour mask instead, ORed in by getPayingColors. That narrows the
reach to the four callers with colour-set semantics - Converge,
ManaColorsPaid, and the two ManaSpent/ManaNotSpent checks - each of which
reads zero today for a spell the AI has not cast yet.

Also drop ManaCostBeingPaid.getSunburst, whose only caller was the
getConvergeCount body replaced in the previous commit. It was
ColorSet.fromMask(sunburstMap).countColors() over the same field
getColorsPaid returns, now inlined at the one site that wanted the count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Announcing X on a converge card runs a payment simulation per candidate X;
predictConvergePayment then ran one more to read back the colours that X had
just bought. setXForBestConverge now returns the winning mask and
setMaxXValue records it, so the extra pass is gone - measured 13 -> 12
simulations casting Chamber Sentry off five lands.

Recording it there means the ~60 setMaxXValue callers can set the prediction,
not just canPlayAndPayFor, which is the only place that clears it. So
getPayingColors now consults the prediction only while nothing has been
spent; any real payment wins outright. That also covers the simulation AI,
which announces X through SpellAbilityChoicesIterator and never goes through
canPlayAndPayFor at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An alternative to the two commits before it, for the "AI only stuff in the
rules engine" concern - these are not meant to both land, so drop whichever
you do not want.

Nothing is parked on the spell. getPayingColors asks the activating player's
controller what it means to spend while nothing has been spent;
PlayerController answers zero, so a human is unchanged, and
PlayerControllerAi forwards to what AiController worked out. One seam still
serves Converge, ManaColorsPaid and both ManaSpent checks, and there is no
mask left to go stale.

Scoped twice: canPlayAndPayFor hands back whatever the outer spell was
expecting, so a nested evaluation cannot lose the outer prediction, and
chooseSpellAbilityToPlay resets it next to AiCache and predictedCombat.
HELD_MANA_SOURCES_FOR_NEXT_SPELL four lines above is the same species of
state - good for one spell, kept AI side, reset at priority.

It costs more than the field it replaces, and the prediction becomes
controller-scoped rather than spell-scoped, so a runWithController swap
loses it. Both cases degrade to zero, which is the answer before any of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four AI callers all had the same shape: work out the amount with
calculateAmount, then override it for Count$Converge. That override existed
because calculateAmount returned zero before anything had been paid, which is
the problem this branch set out to fix. With the prediction in place the
engine's own expression already gives the right number, so the override is
computing a full payment simulation to arrive at a value the line above it
already holds.

Checked rather than assumed: instrumenting the Count$Converge branch in
AbilityUtils and tagging each call with its caller shows 21 of them arriving
via ChangeZoneAi.hiddenOriginCanPlayAI - the AI deciding whether to cast, not
resolution - with castSA set and the count correct. So calculateAmount is
answering properly while the AI is still choosing.

PermanentAi's converge loop was already replaced by setXForBestConverge
earlier on this branch, so removing these four leaves getConvergeCount with no
callers at all and it goes too.

338 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Painful Truths is a Count$Converge draw spell the AI will actually cast, so it
exercises the path DrawAi's override used to serve. Five lands, a 2B spell, so
the best it can reach is three colours - it draws three and pays three life.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sweep the Skies announces X from one SVar and reads Count$Converge from
another, so neither the per-API override nor PermanentAi's loop sizes it.
It gets one thopter instead of five without the prediction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same spell reaches setMaxXValue twice on the way to a decision - once
from the prediction, once from its own API logic - and each walk solves the
payment at every X. Chamber Sentry with nine lands spent 20 solves to answer
the same question twice; it now spends 6.

The walk also ran to the affordable maximum after the answer could no longer
improve, so it stops once all five colors are bought.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@liamiak
liamiak force-pushed the ai-converge-prediction branch from 9311877 to 55ebb1c Compare August 14, 2026 22:34
@liamiak

liamiak commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Rebased — #11515 is in, so the PermanentAi loop is out of this branch.

I re-ran the tests against plain master to see what the rest is still worth: Chamber Sentry and
Skyrider Elf now pass without any of this, so those two tests stopped proving anything and I have
stopped claiming them. Only Bring to Light still fails. I added one more for Sweep the Skies, whose
TokenAmount$ Y reads Count$Converge off a different SVar than X announces — one thopter on
master, five with the prediction.

On prediction storing: I had a go while this was waiting. It sits on AiController keyed by spell
identity, reset at priority next to AiCache.clear(), and canPlayAndPayFor snapshots and restores
it so a nested evaluation cannot clobber the outer spell's answer. Keeping the announced X with the
colours is also what settles your recompute — Chamber Sentry off five lands went 12 payment solves
to 6, and off nine lands, with a five-colour early exit, 20 to 6.

No rush from me. If you would rather land your own version of the storing, say so and I will drop
this to just the Sweep the Skies and tutor pieces.

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