Feat: Mana Ability Rule Change (Apply CR 605.1a) - #11778
Conversation
CR 605.1a, as of the August rule update (2026-08-07), adds a fourth criterion to the definition of a mana ability: its cost and effect must not move any card to or from a library. This commit introduces `SpellAbilityEffect.movesCardToOrFromLibrary()`, defaulting to false, so that adding a new effect can never silently redefine what counts as a mana ability. Effects that always move library cards (Draw, Mill, Surveil, Dig, DigUntil, Discover, Learn, Explore) default automatically to true, whereas other effects where it depends on the script's zone parameters (i.e. ChangeZone, ChangeZoneAll, Play) rely on the new `SpellAbilityEffect.zoneParamIsLibrary` utility to assess the mana ability state. Scry, RearrangeTopOfLibrary, Shuffle, Reveal and PeekAndReveal keep the inherited false, since reordering or looking at a library moves nothing. It's worth nothing that the new predicate reads the ability's script rather than game state, because CR 605.1a says to disregard replacement effects other than self-replacement effects when evaluating the criteria. This predicate will be applied in isManaAbility() - nothing else should call this predicate directly!
This commit contains implementation for the second half of the CR 605.1a criterion: a mana ability's cost must not move any card to or from a library, so paying a cost needs the same utility function defined for effects, i.e. `SpellAbilityEffect.movesCardToOrFromLibrary()`. A new `LibraryMovementCostVisitor` over the existing `ICostVisitor` that returns true when paying a cost would move a card to or from a library. `CostMill` and `CostDraw` always do, in the library-to-graveyard and library-to-hand directions. `CostPutCardToLib` always does, in the other direction. `CostExile` does only when its zone list names the library, since that same cost part also exiles from hand, graveyard, battlefield and stack. Every other cost part is left unhandled, inherits null from ICostVisitor.Base, and reads as false. So a cost the visitor does not recognise leaves the ability a mana ability, which is current behaviour. `Cost` now includes `movesCardToOrFromLibrary()` alongside `hasTapCost()` so the call site in `isManaAbility()`` reads like the rule. Costs that only look at cards, such as revealing from hand or from the top of a library, move nothing and stay false. That is what keeps Metalworker and Sacellum Godspeaker mana abilities working.
CR 605.1a now disqualifies an activated ability from being a mana ability when its cost or effect moves a card to or from a library. Wire the predicate from `SpellAbilityEffec`t and the cost visitor from `Cost` into `isManaAbility()`. Only the root ability's cost is examined, since sub-abilities have no cost of their own. The SubAbility chain is however walked in full instead of returning as soon as a mana part turns up. "Chromatic Sphere alike effects" are the main reason: the mana part sits on the root and the draw sits on a sub-ability, so the early return never saw the draw.
Eight cases in GameSimulationTest, asserting on `Card.getManaAbilities()`, which filters on `SpellAbility.isManaAbility()` and is what the rest of the engine consumes. Must lose the mana ability: - Chromatic Sphere for a draw on a sub-ability, - Charmed Pendant and Deranged Assistant for a mill cost on an artifact and on a creature; - Selvala, Explorer Returned for the multi-player parley. Each also asserts the ability is still there as a non-mana ability, so a later bug cannot quietly delete it instead of reclassifying it. Must keep it: - Barbed Sextant, whose draw is in a delayed trigger; - Shaun & Rebecca, Agents, whose mill is in a reflexive trigger (even though this feels more a judgment call than a settled ruling on the ability. The reasoning was: an ability that creates a reflexive trigger moves no card itself, so it stays a mana ability.) - Metalworker, whose cost reveals from hand, and - Forest as a regression guard against the clause catching basic lands.
|
I'm unsure if we need extra class for @tool4ever your opinion? |
|
While there is no ManaAbility with that, but there are some more that might alter the Library:
While there is no ManaAbility with that, but these Arena Effects kinda mess with the Library too:
|
I guess it's one way to keep the logic together in one class 🤔 |
Yes, that's exactly the idea behind that. Happy to inline that directly into |
This is genuinely a good catch! Thanks. I'll add overrides for those.
Interestingly, none of these show up in a mana ability today, so no card should be affected, but we would be ready in case that will happen. |
Seek, Heist and Connive always move a card to or from a library. ManifestBase does too unless ChoiceZone points elsewhere, since it defaults to TopOfLibrary, and Cloak, Manifest and Manifest Dread all extend it. No mana ability uses any of these today and no card changes behaviour, the sweep over the whole `cardsfolder` still result into the same list. Nonetheless, the base predicate defaults to false, so leaving those out would have been a silent miss once one is scripted (e.g. Custom cards?). Because there is no card to test these, new tests now exercise the predicates directly: ApiType rows for the effects, zone-parameter cases for ChangeZone, Play and Manifest, and a handful of cost strings.
FindingsLibrary movement predicate implementation1. zoneParamIsLibrary uses substring contains and is case-sensitiveThe helper return sa.getParamOrDefault(param, "").contains(ZoneType.Library.toString());
Test case: SpellAbility sa = new SpellAbility.EmptySa(ApiType.ChangeZone, card);
sa.putParam("Origin", "TopOfLibrary");
boolean moves = ApiType.ChangeZone.getSpellEffect().movesCardToOrFromLibrary(sa);
Impact: false positives for descriptive param values that happen to contain the substring "Library", and false negatives if card scripts ever use lower-case zone names. Recommended fix: parse the param as a comma-separated list of zone names and compare equality ignoring case, e.g.: String v = sa.getParamOrDefault(param, "");
for (String part : v.split(",")) {
if (part.trim().equalsIgnoreCase(ZoneType.Library.name())) return true;
}2. LibraryMovementCostVisitor does not cover all library-moving cost types
Costs that move a card to/from a library via other mechanisms are not covered. Test case: Cost c = new Cost("T ExileFromTop<1/Card>", true);
boolean moves = c.movesCardToOrFromLibrary(); // true via CostExile.fromWorks. However: Cost c2 = new Cost("T PutOnTop<1/Card>", true); // hypothetical cost that puts a card on top of library
boolean moves = c2.movesCardToOrFromLibrary();If the parser creates a The visitor inherits Recommended fix: add a default 3. isManaAbility cost check is root-only
final Cost cost = getPayCosts();
if (cost != null && cost.movesCardToOrFromLibrary()) return false;Only the root ability's pay costs are inspected. Sub-abilities do not have separate pay costs in Forge's model, so this is likely intentional, but the rule text says "its cost or effect". If a future card script assigns a cost to a sub-ability, the library-moving cost would be missed. Test case: // Hypothetical ability with a mana part on the root and a sub-ability that has a pay cost mill
SpellAbility root = ...;
root.setSubAbility(subWithMillCost);
root.isManaAbility() // cost check only looks at root, not subCurrently returns based on effect check only. Recommended fix: document the assumption that sub-abilities never carry pay costs, or walk the chain and inspect 4. Test assertions are overly strict for cards with multiple abilities
AssertJUnit.assertTrue(cardName, c.getManaAbilities().isEmpty());The data provider expects the card to lose its mana ability. If a card has multiple activated abilities and only one of them is a mana ability that moves a library, the other mana abilities would still be present, causing the test to fail even though the targeted ability is correctly classified. Test case: Card c = manaAbilityTestCard("SomeCardWithTwoAbilities");
SpellAbility manaSA = manaAddingActivatedAbility(c); // picks first mana-adding SA
AssertJUnit.assertFalse(manaSA.isManaAbility());
// but c.getManaAbilities() may still contain a different mana ability
AssertJUnit.assertTrue(c.getManaAbilities().isEmpty()); // false positive failureThe assertion conflates "the tested ability is no longer a mana ability" with "the card has no mana abilities at all". Recommended fix: assert only that the specific ability returned by 5. ManifestBaseEffect default assumes library
return !sa.hasParam("ChoiceZone") || zoneParamIsLibrary(sa, "ChoiceZone");If Test case: SpellAbility sa = new SpellAbility.EmptySa(ApiType.Manifest, card);
sa.putParam("ChoiceZone", "");
boolean moves = ApiType.Manifest.getSpellEffect().movesCardToOrFromLibrary(sa);
// moves == false, but the intent is ambiguousRecommended fix: treat blank VerdictThe PR correctly implements the CR 605.1a intent and the test suite demonstrates the intended card changes. However, the library-movement predicates rely on fragile string contains checks, the cost visitor is open to silent misses for new cost types, and the test assertions are stricter than necessary. These issues do not break the current card set but create maintenance risk. Recommended Fixes
Reviewed by Hermes Agent - muse-glimmer-30b |
…r tests zoneParamIsLibrary stays a substring test because card scripts write Destination$ TopOfLibrary and BottomOfLibrary, which name the library specifically without being zone names, but it now ignores case (as correctly pointed out!). ManifestBase treats a blank ChoiceZone as absent, so it falls back to the top of the library rather than out of scope. Added a quick note (top comment) on top of isManaAbility pointing out that only the root ability's cost is inspected, matching the assumption getCostDescription already makes. The card tests now assert that the specific mana-adding ability is no longer offered as a mana source. Also included cases for a TopOfLibrary destination and a blank ChoiceZone.
|
Thank you @jamincollins for the useful insights. I believe that suggestions 1. Substring check. 2. Cost visitor. 3. Root-only cost. Checking "root-only" is intentional. 4. Test assertion. Fixed, now asserts 5. Blank One last note: the last recommendation has been already included in Many thanks! |
jamincollins
left a comment
There was a problem hiding this comment.
Approved per adversarial review and author fixes in 84a5b49
| public boolean movesCardToOrFromLibrary(final SpellAbility sa) { | ||
| // manifests the top of the library unless ChoiceZone points somewhere else. A blank | ||
| // ChoiceZone is treated as absent, so it falls back to the library rather than out of scope. | ||
| return StringUtils.isBlank(sa.getParam("ChoiceZone")) || zoneParamIsLibrary(sa, "ChoiceZone"); |
There was a problem hiding this comment.
Use hasParam instead of checking for Blank
| * not zone names, so parsing the value as a ZoneType list would miss them. | ||
| */ | ||
| protected static boolean zoneParamIsLibrary(final SpellAbility sa, final String param) { | ||
| return StringUtils.containsIgnoreCase(sa.getParamOrDefault(param, ""), |
There was a problem hiding this comment.
ZoneType.listValueOf probably better check
With checking hasParam before
The 2026-08-07 rules update added a fourth criterion to
605.1a:Forge implemented the other three criteria, so abilities that draw or mill still bypassed the stack.
Selvala, Explorer ReturnedandChromatic Sphereare the two cards Wizards' own coverage names as the reason for the change, however this is the list of all the cards that should be affected (most of them verified in Testing - see below).Approach
Mana-ability status is derived structurally in
SpellAbility.isManaAbility(),so the fix is in the engine and no card scripts change.
SpellAbilityEffect.movesCardToOrFromLibrary(), defaults to false,overridden in Draw, Mill, Surveil, Dig, DigUntil, Discover, Learn, Explore,
and conditionally in ChangeZone, ChangeZoneAll and Play.
LibraryMovementCostVisitoron the existingICostVisitor, exposed asCost.movesCardToOrFromLibrary().isManaAbility()as the main point of activation.Unhandled cases resolve to false, so anything unrecognised stays a mana ability.
The chain is now walked in full (including also sub-abilities) rather than returning on the first mana part, since Chromatic Sphere has the mana part on the root
and the draw on a sub-ability.
Only the root's cost is checked. Nothing reads game state, per the
rule's instruction about replacement effects.
Card impact
12 cards of 1867 lose a mana ability, verified by two independent passes over all
33,669 card scripts and a cross-check against Scryfall's 38,630 unique oracle cards.
(This bit was done with support of a quick Python script I coded to look for specific keywords in cards text and forge card scripts implementation)
Tests
Two data-driven tests in
GameSimulationTest: 12 rows for the cards that losethe ability, 9 controls covering each reason a card is deliberately unaffected,
including a basic land as a guard. The commits are split so the two predicates
land inert and the behavioural change is one revertible commit.