Skip to content

Skip rebuilding statics and triggers when every source is empty - #11964

Open
MostCromulent wants to merge 1 commit into
Card-Forge:masterfrom
MostCromulent:empty-static-ability-fast-path
Open

MostCromulent wants to merge 1 commit into
Card-Forge:masterfrom
MostCromulent:empty-static-ability-fast-path

Conversation

@MostCromulent

Copy link
Copy Markdown
Contributor

Summary

CardState.getStaticAbilities() and getTriggers() 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. 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 is hasState(CardStateName.LeftSplit), the same test the rebuild performs three lines below, rather than isSplitCard(). isSplitCard() calls getRules(), which calls getPaperCard(), 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.

workload before after
token decks 38,433 ms 32,802 ms −14.6%
ordinary constructed 5,501 ms 4,635 ms −15.7%
four-player commander 110,786 ms 107,998 ms −2.5%

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

  • Game logs are identical between the two builds in every run on all three workloads, with identical turn counts.
  • A counter comparing the guard's decision against the list the slow path actually builds recorded no wrong decision across 564M getStaticAbilities and 52M getTriggers calls.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant