Only let a card be cast from its owner's hand or graveyard - #11398
Only let a card be cast from its owner's hand or graveyard#11398liamiak wants to merge 4 commits into
Conversation
f054a5f to
efb832c
Compare
|
don't really want more workarounds in this area, try researching why the check that prevents it had to be limited to Desktop: 07e4109 |
checkZoneRestrictions compared zone types only, so a spell whose restriction zone matched the card's zone was playable no matter whose zone it was - flashback, jump-start, retrace, escape, disturb and aftermath all leaked the same way. The guard that used to stop this lived in PlayerControllerHuman and was gated on a mouse-click event, so it never ran on mobile or netplay. This puts the check where the grant paths already null out the restriction zone, so permission and a click are distinguishable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getAbilityToPlay re-derived whether a card was in the player's own zone, duplicating what canPlay already decides. It was gated on a mouse-click event, so it only ever ran on local Desktop play, and its FIXME asked for the check to move earlier. With the zone restriction enforced in SpellAbilityRestriction, the click path returns no abilities at all for another player's card - spells fail canPlay, and AbilityActivated.isPossible defers to the same zone and activator restrictions - so InputPassPriority returns before this method is reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
efb832c to
8caf6e6
Compare
|
Researched it. The short version is that the check you pointed me at is not a check that needs Why it was limited to Desktop07e4109 added That proxy is what confines it to Desktop. return syncAndSendAndWait(ProtocolMethod.getAbilityToPlay, hostCard, abilities, null/*triggerEvent*/);
//someplatform don't have mousetriggerevent class or it will not allow them to click/tap— so it is skipped on mobile and on netplay. The FIXME directly under it says so, and asks for this The proxy cannot be repaired in place, because a UI controller has no way to tell permission from a
Both GUIs share that call site — Two things I had wrongShaman's Trance. It grants a keyword rather than a
The old description claimed no spell is ever restricted to a zone other than hand or graveyard. Tests now cover each zone a spell can be restricted to: graveyard (owner, non-owner, the granted Two limits I would rather state than paper over. Nothing here exercises the human GUI, so the guard |
tool4ever
left a comment
There was a problem hiding this comment.
Well now I can cast any spell from my opponents hand with As Foretold...
Fixes #11319. Nine added lines in the engine and twenty-four removed from the GUI; the test is a
droppable middle commit and the guard removal a droppable last one.
The bug
SpellAbilityRestriction.checkZoneRestrictionscompared zone types only, so a spell whoserestriction zone matched the card's zone was playable no matter whose zone it was. The reporter hit
it through the graveyard, which is the only one of these zones a player can click on.
Reproduced with the reported card, and it is not flashback-specific — every keyword that says
"from your graveyard" leaks the same way:
Unearth and Embalm were already correct. They are activated abilities, and
checkActivatorRestrictionsresolves againstc.getController(), which off the battlefield is theowner. Spells never reach that comparison intact, because
Spell.canPlayFromHostcopies the cardand sets the activator as its controller before the restrictions run. That is why the fix has to
ask for the owner.
Why the check belongs in the rules layer
There was already a guard for this, in
PlayerControllerHuman.getAbilityToPlay, but it was gatedon
triggerEvent != null(07e4109, "Fix NPE from PlayEffect"). The two-argPlayerController.getAbilityToPlaypassesnullfor the trigger event, and that overload is whatPlayEffect,DiscoverEffect,ChangeZoneEffectandPlaySpellAbilitycall, so the gate wasstanding in for "did a human click this, or is an effect offering it?".
ITriggerEventis a mouse event, so that proxy confined the guard to local Desktop play. The mobileGUI never produces one, and
RemoteClientGuiGamepassesnulldeliberately —"someplatform don't have mousetriggerevent class or it will not allow them to click/tap". The FIXME
under the guard recorded the result and asked for exactly this fix: "on mobile gui it allows the
card to cast from opponent hands issue #2127, investigate where the bug occurs before this method is
called" (a pre-migration issue number).
A UI controller cannot tell permission from a click, so the proxy could not be repaired in place.
The restriction layer can, because every grant path clears the zone before the check runs.
The fix
A zone restriction means the activator's own zone. CR 109.5: a card outside the battlefield has no
controller, so the "you" in a permission to cast it from there is its owner.
There is no zone list, because a spell's restriction zone is only ever set in Java —
HandbySpell's constructor,Graveyardby the six alternative-cost builders,Exileby foretell andplot, or
nullby a grant. NoSP$card script usesActivationZone$at all, and the four scriptsthat name
Battlefieldare activated abilities, whichsa.isSpell()excludes.Shaman's Trance is carved out because it arrives as a keyword rather than a
MayPlay, and the twosites in
StaticAbilityContinuousthat honour it only widen existingMayPlaystatics — flashbackis an alternative cost, so neither covers it. This is the same carve-out the Desktop guard made.
Removing the Desktop guard
With the rule enforced in the restriction layer the guard is unreachable.
InputPassPriorityasks the game state first, and returns before
getAbilityToPlayif nothing comes back:On a click
removeUnplayableis false, so unplayable abilities are normally kept and showndisabled — but both paths that decide route back through the same restrictions. Spells take
isPossible()'s default ofcanPlay(), and the one override,AbilityActivated.isPossible(),returns
checkZoneRestrictions(...) && checkActivatorRestrictions(...). Measured on that call foranother player's cards, with this fix in place:
Both GUIs go through that one call site —
forge-gui-mobilehas no ability lookup of its own — sothis is also what closes the mobile hole the FIXME describes.
What still works
Casting from someone else's zone is meant to need a grant from another card, and every one of those
paths sets the restriction zone to
nullbefore the check runs:GameActionUtil.getMayPlaySpellOptions—sar.setZone(null). Covers static and effect-basedMayPlay$, so Mnemonic Betrayal, Hedonist's Trove, Dire Fleet Daredevil and the rest.AbilityUtils.getSpellsFromPlayEffect—newSA.getRestrictions().setZone(null). CoversDB$ Play, so Wrexial, Arcane Heist, Yawgmoth's Will, Underworld Breach. This one also passeswithAltCost, so it keeps offering the flashback cost too, and the test asserts that.Casting your commander is in the first group:
Player.createCommanderEffectgrantsMayPlay$ True | Affected$ Card.IsCommander+YouOwn | AffectedZone$ Command, so its zone is nulledand it is already owner-scoped. This check never sees it.
Testing
mvn -pl forge-gui-desktop -am test: 288 tests, 0 failures.granted
DB$ Playpath still finds the spell, Shaman's Trance still reaches another graveyard)and exile (a foretold card, castable by its owner only). Foretell is barred on the turn the card
was exiled, so that case has to let a turn pass first.
measurements above rather than on a test. And the command zone cannot be set up in this harness —
createCommanderEffectruns during game start, which the test harness skips — so that reasoningis a code read.
🤖 Implemented with the assistance of Claude Code (Opus 5).