Skip to content

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

Open
liamiak wants to merge 7 commits into
Card-Forge:masterfrom
liamiak:ai-converge-prediction
Open

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

Conversation

@liamiak

@liamiak liamiak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Supersedes #11485 — the two were halves of one change, and the duplicated payment simulation you
flagged could only be resolved in one place.

Converge and sunburst count the colours actually spent, which nothing has while the AI is still
deciding. Two things followed from that.

X was never announced to buy them. PermanentAi measured its converge baseline at the maximum X
setMaxXValue leaves behind, so its walk always compared worse and X collapsed to 0. That loop is
deleted — setMaxXValue is where every X announcement already goes. It still returns the largest
affordable X, so the xPay <= 0 checks at its 66 call sites are unchanged, and it only acts on a
spell, since converge counts colours spent casting. Sweep the Skies is then fixed without touching
TokenAi.

card before after
Chamber Sentry never cast — at X=0 a free 0/0 that dies 5/5
Skyrider Elf 2/2 5/5
Sweep the Skies one thopter five

Count$Converge read 0 while deciding. getCastSA() is null before the spell is cast, so Bring
to Light searched for a 0-drop and never cast. canPlayAndPayFor already lends the card its
castSA for this reason; it now lends the payment too. getConvergeCount derives from
getConvergeColors, so there is one simulation rather than two.

Then it tutored for the worst card. chooseCardToHiddenOriginChangeZone treats
Destination$ Exile as dumping junk, so on your own library it takes getWorstAI — Bring to Light
picked a Runeclaw Bear over a Serra Angel 18/18. 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 — that needs board-aware selection, separately.

Each commit fails its own assertion without it. 338 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?

liamiak1 and others added 4 commits August 2, 2026 10:06
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>
liamiak1 and others added 3 commits August 2, 2026 12:02
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>
@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.

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