Linked ability framework for Zinnia Valley's Voice#14625
Linked ability framework for Zinnia Valley's Voice#14625sneddigrolyat wants to merge 13 commits intomagefree:masterfrom
Conversation
OffspringAbility.java updated with new OffspringDelayedTriggeredAbility for implementation of ZinniaValleysVoice
Add Zinnia, Valley's Voice
Add ZinniaValleysVoice.java in conjunction with changes to OffspringAbility.java with new OffspringDelayedTriggeredAbility
|
@ssk97 Thanks, your comment was helpful. Replying here since you noted it belongs on the PR. I ended up changing the approach in a few important ways based on the concerns you raised.
Also backed away from the broader
I added coverage for the cases you called out:
Current local validation:
The main thing I’d like review on is whether this narrower linkage-preservation model for granted abilities plus copied permanent spells is the right way to go. |
This PR targets the engine bug from #12752 that Zinnia exposed, not just the single card implementation in #12568.
The underlying issue was that linked additional-cost / ETB state for mechanics like Offspring was not staying correct across:
Earlier attempts in #14595 and #14609 improved the symptom, but they still relied on workaround-ish handling and did not hold up cleanly for the broader linked-ability model.
This should also be reusable for future cards that grant a cost-linked spell ability whose ETB or copied trigger must remember which specific cost instance was paid; current adjacent patterns include granted Prowl, Blitz, and Freerunning, though only Offspring and Squad are explicitly migrated to linkage-aware tagging in this PR.
What changed
Ability.remapForSource(UUID)support so granted abilities can be copied onto cards/spells with a stable per-source identity and linkage instead of sharing raw ability state.GainAbilityControlledSpellsEffectto grant remapped ability copies, which keeps multiple granted Offspring instances independent.TriggeredAbilities.checkTriggerto prefergame.getPermanentEntering(sourceId)for ETB validation. This fixes copied permanent spells whose ETB validation could otherwise look at the copied card/spell object instead of the entering permanent.Mage.Testssweep.##Zinnia on the battlefield, cast a creature spell, choose Offspring, let it resolve, then watch the ETB trigger.
Flow
Zinnia static ability is active
-> GainAbilityControlledSpellsEffect.apply(...)
-> copies Offspring onto each matching creature card/spell
-> remapForSource(seed)
-> that granted ability gets a stable linkageId for this grant instance
You cast the creature spell
-> OffspringAbility.addOptionalAdditionalCosts(...)
-> if you pay {2}, the spell ability stores:
offspringActivation|
The creature spell resolves
-> Spell.resolve(...)
-> storePermanentCostsTags(stack-MOR -> paid-cost tags)
-> storeEnteringAbilities(future-permanent-MOR -> gained abilities)
-> move card from stack to battlefield
The first apply-effects pass after ETB
-> ZonesHandler creates PermanentCard and marks it as "entering"
-> GameState.applyEffects(...)
-> battlefield.reset(...)
-> applyEnteringAbilities(...)
-> the gained Offspring ETB ability is attached to the entering permanent
ETB trigger evaluation
-> TriggeredAbilities.checkTrigger(...)
-> uses game.getPermanentEntering(sourceId) first
-> OffspringTriggeredAbility.checkTrigger(...)
-> looks up offspringActivation|
-> if found, the ETB trigger is created
ETB trigger resolves
-> OffspringEffect.apply(...)
-> uses copied-trigger target pointer if present, else source permanent
-> creates the 1/1 token copy
The branch's actual insertion points are:
Ability identity/linkage:
AbilityImpl.java:160
CardUtil.java:1967
id can change, but linkageId is what ties cast-time payment to the ETB ability.
Granted-ability handoff from stack to permanent:
GainAbilityControlledSpellsEffect.java:45
Spell.java:359
GameState.java:1559
PermanentCard.java:102
Trigger validation on ETB and copy cases:
TriggeredAbilities.java:231
OffspringAbility.java:161
PermanentToken.java:101
SpellAbility.java:263
Breakpoints
For copies, the divergence is:
copy spell on stack
-> SpellAbility.copySpell / Spell.copySpell
-> new runtime id, same linkageId
-> if it resolves as a token permanent:
PermanentToken(... preserveCopiedSpellLinkage=true)
-> addAbilityKeepingLinkage(...)
That is why copied Offspring/Squad spells and copied ETB triggers still read the right paid-cost state.
Squad is the same pipe, except it stores an integer count instead of a presence tag in SquadAbility.java:101.
Coverage
Added/updated regression coverage for:
Spark Double) with one granted payment and with two granted payments.Validation
Automated:
OffspringTestpassedZinniaValleysVoiceTestpassedSquadTestpassedMeldTest#testMeld_Urza_Eliminate_After_RollbackpassedJaceTest#rollbackDoesntUnflipJaceTestpassedStateValuesTest#rollbackTokenCreationTestpassedLatest confirmed broad run:
mvn -pl Mage.Tests -am testSimulationTriggersAITest.test_DeepglowSkate_PerformanceOnTooManyChoicesmaster, so it does not appear to be caused by this branchManual:
March 19, 2026:
Main review question
Does this narrower model for preserving linkage on granted abilities and copied permanent spells look like the right CR 607 layer for XMage, without reintroducing the broader copied-permanent regressions from the earlier experiment?