Skip rebuilding statics and triggers when every source is empty - #11964
Open
MostCromulent wants to merge 1 commit into
Open
MostCromulent wants to merge 1 commit into
MostCromulent wants to merge 1 commit into
Conversation
CardState.getStaticAbilities() and getTriggers() allocate a fresh FCollection on every call, copy the state's own list into it, add any split state's contribution, then hand it to Card.updateStaticAbilities/updateTriggers to walk the changed trait tables and the keyword collection. Across three AI-vs-AI games between mirrored Goblin token decks that happens 262.6M times and 99.4% of the results are empty. Return the shared empty collection when the state's own list, its keywords, both trait change tables and its split halves are all empty. Every step after the base list either adds or removes, and a removal cannot make an empty list non-empty, so an empty base with no adder means an empty result. LandTraitChanges only clears for these two traits, which is why hasRemoveIntrinsic() is absent from the test; that does not hold for getReplacementEffects, where the same step adds loyalty, defense and saga effects, so it is untouched. The split test uses hasState(LeftSplit), the predicate the rebuild itself uses three lines below, rather than isSplitCard(). isSplitCard() calls getRules() twice and getRules() calls getPaperCard() twice; on a card with no paper card, which is every emblem and command zone effect, getPaperCard() runs five uncached card database lookups. A version of this guard using isSplitCard() measured 17% slower on four-player commander. Four repetitions per arm, arms alternated and the order reversed between repetitions: token decks 38433 -> 32802 ms, ordinary constructed 5501 -> 4635 ms, four-player commander 110786 -> 107998 ms. Token deck arms do not overlap; commander arms do, so no gain is claimed there. Game logs and turn counts are identical on every run of all three workloads, and a counter comparing the guard's decision against the list the slow path builds found no wrong decision across 564M getStaticAbilities and 52M getTriggers calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CardState.getStaticAbilities()andgetTriggers()allocate and populate a fresh collection on every call, and across three AI-vs-AI games between mirrored Goblin token decks 99.4% of those calls return empty. When the state's own list, its keywords, both trait-change tables and its split halves are all empty, the result cannot be anything but empty, so the getters now return a shared empty collection instead of rebuilding one. AI games are faster on every workload measured.This complements the trait-caching work in #11314 and #11409 rather than competing with it. A cache still computes the value on every miss and on every call outside its scope; this removes the computation itself.
Implementation
Every step after the base list either adds to it or removes from it, and a removal cannot make an empty list non-empty. So an empty base with no adder gives an empty result. The guard tests exactly the adders: the state's own list, the keyword collection, both changed-trait tables and the split states.
LandTraitChangesonly clears for these two traits, which is whyhasRemoveIntrinsic()is absent from the test. That does not hold forgetReplacementEffects, where the same step adds loyalty, defense and saga effects, so it is untouched.The split test is
hasState(CardStateName.LeftSplit), the same test the rebuild performs three lines below, rather thanisSplitCard().isSplitCard()callsgetRules(), which callsgetPaperCard(), and on a card with no paper card — every emblem and command-zone effect — that runs five uncached card-database lookups.Measurements
AI-vs-AI games, four repetitions per arm, arms alternated and the order reversed between repetitions.
On token decks every run with the change was faster than every run without it. On commander the two sets of times overlap, so −2.5% is better read as no change than as a gain. It is not a cost either: the new build was faster in all four pairs.
The guard fires on 96% of calls in token games and 61% in commander, which is why commander gains least.
Testing completed
getStaticAbilitiesand 52MgetTriggerscalls.🤖 Generated with Claude Code