Repository navigation
Conversation
| 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)); |
There was a problem hiding this comment.
don't like having to create extra fake Mana
what are the alternatives?
There was a problem hiding this comment.
You're right — I missed this one on the first pass. Working it now, expect another push.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Mhn, I'll think about it though a lot of work has previously gone into removing AI only stuff from the main rules engines...
There was a problem hiding this comment.
Coming back to this properly - you were right, and that field is gone.
What is left in forge-game is +17/-4 across three files: ManaCostBeingPaid loses getSunburst() (getColorsPaid() already returned the mask), PlayerController gains a default getExpectedPayingColors(sa) returning 0, and getPayingColors() asks it only while payingMana is still empty.
So nothing AI-side is stored in the rules engine now - it gained a question it can ask, in the same shape as the rest of PlayerController. PlayerControllerAi is the only override, so on the human path the answer is 0 and ColorSet.fromMask(0) is exactly what the old loop returned over an empty payingMana: unchanged rather than merely equivalent.
On runtime: every getPayingColors() call site is behind a script-level guard (ManaSpent, ManaNotSpent, Count$Converge), which is 65 of 33,664 card scripts, so the controller is never asked in a loop. The AI half also moved from computing this up front to working it out on first read, and returns 0 immediately unless the card actually has converge.
Rebased onto current master; suite 356/0/6.
Sorry this sat - it was reworked on the 16th and I answered in a general comment rather than here, so the thread still read as open.
| /** | ||
| * Return the colors that would be used for payment, as a color mask. | ||
| */ | ||
| public static byte getConvergeColors(final SpellAbility sa, final Player ai) { |
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
well the code duplication is gone but the method is still used and calculates again for no reason?
There was a problem hiding this comment.
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.
29244af to
e1cbad2
Compare
|
Pushed two commits.
It parks nothing on the spell. Scoped twice — 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 |
|
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 |
9311877 to
55ebb1c
Compare
|
Rebased — #11515 is in, so the I re-ran the tests against plain master to see what the rest is still worth: Chamber Sentry and On prediction storing: I had a go while this was waiting. It sits on No rush from me. If you would rather land your own version of the storing, say so and I will drop |
|
yea that looks too messy :/ it might be easier to implement a new generic helper that can also cover other scenarios, e.g. Fling or if there are any similar mechanics where a payment decision has such high impact on the effect? |
55ebb1c to
c870505
Compare
|
Both good calls, and the first changed the shape of it. Eager → lazy. My first attempt left the getter announcing X as a side effect of a read, which is worse than what it replaced. Deleting that branch failed the Sweep the Skies test, so it was load-bearing rather than dead — Sweep the Skies. Worth knowing: that piece stands alone. Those five lines on plain master, with none of the prediction, already get Sweep the Skies to five thopters (announcing max X rather than the converge-best X, so one mana more). Happy to split it out if you would rather take it separately. On overwriting the SVars: that already exists, On On a generic helper, the family already splits three ways:
A helper covering all three would be a controller-answered "what do you intend to pay", with colours as the first instance. Glad to take that on separately rather than widen this PR into it speculatively. On the tests, so you don't have to work out which is which: reverting the source to master and keeping the tests, two of the four fail — Bring to Light (not cast at all) and Sweep the Skies (one thopter). The Chamber Sentry and Painful Truths ones pass either way; they guard the removed Rebased on master; suite 356/0/6, checkstyle clean, and the same 356/0/6 on the branch without these two commits. |
c870505 to
c5be6aa
Compare
c5be6aa to
951e298
Compare
|
Went back at "too messy" and you were right about where the weight was. Two commits on top, The prediction no longer needs storage of its own. It was four fields, four methods and a
One of the tests was asserting a coin flip. And one could not fail at all. Three left, each failing for its own reason: Bring to Light for the prediction, Painful Truths Still happy to split the Sweep the Skies piece out — measured again on current master, it stands |
A card that reads its own payment - Converge, ManaColorsPaid - gets an empty answer while its controller is still deciding whether to cast it, because nothing has been spent yet. There is no other source for it: the payment does not exist, only an intention to make one. getPayingColors() now asks the activating player's controller when nothing has been spent. PlayerController answers 0, which is bit-identical to what the empty payingMana loop produced, so nothing changes for a human or for any controller that does not override it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177x5FTgdfzYB9BRDFzaN1Y
Count$Converge asks how many colours were spent, so while the AI is deciding it reads zero and every converge effect is sized as if the spell were free - Bring to Light searched for a nought-drop and then declined to cast at all. Solve the payment against the sources actually available and answer with the colours it would use. The answer is held in AiCache, keyed on the player and the spell like evaluateBoardPosition and aiLifeInDanger, so it lasts exactly one decision: AiCache.clear() already runs at the top of chooseSpellAbilityToPlay. Non-converge cards are turned away before the solve, since every spell that reads its own payment arrives here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177x5FTgdfzYB9BRDFzaN1Y
On a converge or sunburst card X does not buy size, it buys colours, so the useful announcement is the smallest X that still reaches as many as the board allows rather than every mana the AI can find. setMaxXValue keeps reporting what it could afford; the announcement is now settled separately. PermanentAi already walked X this way for permanents. That walk moves into the mana code so spells reach it too, and so the answer is cached per decision instead of re-solved by every caller - each step of the walk is a full payment solve. TokenAi needs it before spawnToken: a token count that reads Count$Converge through its own SVar is read before the block that announces X, so nothing would have announced it. Sweep the Skies made one thopter off any board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177x5FTgdfzYB9BRDFzaN1Y
Each of these worked the amount out with calculateAmount and then threw it away and recomputed it, because calculateAmount read zero before anything had been paid. It no longer does, so the engine's own expression is right and the overrides are removing themselves. getConvergeCount goes with them - it counted the colours where the prediction wants the colours themselves - and so does ManaCostBeingPaid.getSunburst, which had no other caller. Bring to Light loses an SVar that only existed to feed one of the overrides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177x5FTgdfzYB9BRDFzaN1Y
chooseCardToHiddenOriginChangeZone reads Destination$ Exile as getting rid of something, so when the searcher owned the library it picked getWorstAI. For a tutor the exile is where the card waits to be played: Bring to Light took a Runeclaw Bear over a Serra Angel. Narrowed to the case that is actually a tutor - the decider searching their own library - so exiling an opponent's library and every other use of this chooser are untouched. Beseech the Mirror, Emergent Ultimatum, Portent of Calamity and three others reach the same line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177x5FTgdfzYB9BRDFzaN1Y
Three cases, each failing on master for its own reason: Bring to Light is not cast at all, Painful Truths draws one card instead of three once the DrawAi override is gone, and Sweep the Skies makes one thopter instead of five. The Sweep the Skies case fixes the random stream for its duration and restores it afterwards. TokenAi casts a token spell TOKEN_GENERATION_ABILITY_CHANCE of the time - 80 on the default profile - so without that it asserts a coin flip and fails about one run in five. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177x5FTgdfzYB9BRDFzaN1Y
3651406 to
914c41f
Compare
| final Random previous = MyRandom.getRandom(); | ||
| MyRandom.setRandom(new Random(1)); | ||
| try { | ||
| Game game = initAndCreateGame(); |
There was a problem hiding this comment.
spacing looks off, also seems like a bad idea to build our tests around assumptions on such minor implementation details
There was a problem hiding this comment.
Spacing fixed.
On the seed: the test is checking that the AI sizes X by the colours it will pay (5 Thopters from Sweep the Skies). But on the default profile TokenAi only casts a token spell 80% of the time, so unseeded, one run in five the spell is never cast and the test fails without reaching the sizing. Pinning the random stream is there to take that coin flip out, not to assert anything about it.
Happy to delete this test if you'd rather not have it. If there's a cleaner way to force the cast, I'll switch to that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuilt on current master as six commits. The old series carried two approaches it later
abandoned, which is what made it hard to read — and what stopped it rebasing.
Count$Convergereads 0 while the AI is deciding. Nothing has been spent yet, so Bring toLight searched for a nought-drop and then declined to cast at all.
getPayingColors()now asks theactivating player's controller what it expects to spend.
PlayerControlleranswers 0 — bitidentical to what the empty
payingManaloop produced — so humans and every controller that doesnot override it are unchanged.
Where the answer lives.
AiCache, keyed on the player and the spell, besideevaluateBoardPositionandaiLifeInDanger. Itsclear()already runs at the top ofchooseSpellAbilityToPlay, so an answer lasts exactly one decision and needs no reset of its own.Nothing is parked on the spell and
AiControlleris not touched. Non-converge cards are turnedaway before the solve, since every card that reads its own payment arrives here.
X announces colours, not size. On a converge card the useful X is the least one that still
reaches as many colours as the board allows, not every mana the AI can find.
PermanentAialreadywalked X this way; that walk moves into the mana code so spells reach it too, and so it is solved
once per decision rather than once per caller — every step of the walk is a full payment solve.
Sweep the Skies is the case no walk reached:
TokenAmount$ YreadsCount$Convergeoff adifferent SVar than X announces, and the count is read before anything announces it. One thopter on
master, five here.
Four
Count$Convergeoverrides are gone. Each computed the amount withcalculateAmount, thendiscarded it and recomputed, because
calculateAmountread zero before payment. It no longer does.And it tutored for the worst card.
chooseCardToHiddenOriginChangeZonereadsDestination$ Exileas getting rid of something, so searching your own library tookgetWorstAI—Bring to Light preferred a Runeclaw Bear to a Serra Angel. Narrowed to the decider searching their
own library, so exiling an opponent's library is untouched. Beseech the Mirror, Emergent Ultimatum,
Portent of Calamity and three others reach the same line.
Engineered Explosives stays flagged — its counters pick which mana value the wipe hits, so most
colours is usually the worst live choice.
Source is +90/-34. Suite 715, 0 failures.
🤖 Implemented with the assistance of Claude Code (Opus 5).