Skip to content

Only let a card be cast from its owner's hand or graveyard - #11398

Open
liamiak wants to merge 4 commits into
Card-Forge:masterfrom
liamiak:fix-flashback-opponent-graveyard
Open

Only let a card be cast from its owner's hand or graveyard#11398
liamiak wants to merge 4 commits into
Card-Forge:masterfrom
liamiak:fix-flashback-opponent-graveyard

Conversation

@liamiak

@liamiak liamiak commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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.checkZoneRestrictions compared zone types only, so a spell whose
restriction 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:

Keyword Card probed Owner Non-owner, before After
Flashback Geistflame casts casts declines
Jump-start Chemister's Insight casts casts declines
Retrace Raven's Crime casts casts declines
Escape Cling to Dust casts casts declines
Disturb Baithook Angler casts casts declines
Aftermath Cut // Ribbons casts casts declines

Unearth and Embalm were already correct. They are activated abilities, and
checkActivatorRestrictions resolves against c.getController(), which off the battlefield is the
owner. Spells never reach that comparison intact, because Spell.canPlayFromHost copies the card
and 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 gated
on triggerEvent != null (07e4109, "Fix NPE from PlayEffect"). The two-arg
PlayerController.getAbilityToPlay passes null for the trigger event, and that overload is what
PlayEffect, DiscoverEffect, ChangeZoneEffect and PlaySpellAbility call, so the gate was
standing in for "did a human click this, or is an effect offering it?".

ITriggerEvent is a mouse event, so that proxy confined the guard to local Desktop play. The mobile
GUI never produces one, and RemoteClientGuiGame passes null deliberately —
"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

if (sa.isSpell() && activator != c.getOwner() && this.getZone() != null
        && !(this.getZone() == ZoneType.Graveyard
                && activator.hasKeyword("Shaman's Trance"))) {
    return false;
}

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 — Hand by
Spell's constructor, Graveyard by the six alternative-cost builders, Exile by foretell and
plot, or null by a grant. No SP$ card script uses ActivationZone$ at all, and the four scripts
that name Battlefield are activated abilities, which sa.isSpell() excludes.

Shaman's Trance is carved out because it arrives as a keyword rather than a MayPlay, and the two
sites in StaticAbilityContinuous that honour it only widen existing MayPlay statics — flashback
is 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. InputPassPriority
asks the game state first, and returns before getAbilityToPlay if nothing comes back:

List<SpellAbility> abilities = card.getAllPossibleAbilities(getController().getPlayer(), triggerEvent == null);
if (abilities.isEmpty()) {
    return false;
}

On a click removeUnplayable is false, so unplayable abilities are normally kept and shown
disabled — but both paths that decide route back through the same restrictions. Spells take
isPossible()'s default of canPlay(), and the one override, AbilityActivated.isPossible(),
returns checkZoneRestrictions(...) && checkActivatorRestrictions(...). Measured on that call for
another player's cards, with this fix in place:

Card in the owner's zone owner other
Geistflame in the graveyard (flashback, a spell) 1 0
Abzan Devotee in the graveyard (activated ability) 1 0
Lightning Bolt in the hand (a plain spell) 1 0

Both GUIs go through that one call site — forge-gui-mobile has no ability lookup of its own — so
this 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 null before the check runs:

  • GameActionUtil.getMayPlaySpellOptionssar.setZone(null). Covers static and effect-based
    MayPlay$, so Mnemonic Betrayal, Hedonist's Trove, Dire Fleet Daredevil and the rest.
  • AbilityUtils.getSpellsFromPlayEffectnewSA.getRestrictions().setZone(null). Covers
    DB$ Play, so Wrexial, Arcane Heist, Yawgmoth's Will, Underworld Breach. This one also passes
    withAltCost, so it keeps offering the flashback cost too, and the test asserts that.

Casting your commander is in the first group: Player.createCommanderEffect grants
MayPlay$ True | Affected$ Card.IsCommander+YouOwn | AffectedZone$ Command, so its zone is nulled
and it is already owner-scoped. This check never sees it.

Testing

  • mvn -pl forge-gui-desktop -am test: 288 tests, 0 failures.
  • The graveyard test fails without the change, on the assertion that the non-owner cannot cast.
  • It covers each zone a spell can be restricted to: graveyard (owner casts, non-owner does not, the
    granted DB$ Play path 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.
  • Two limits worth stating. Nothing here exercises the human GUI, so the guard removal rests on the
    measurements above rather than on a test. And the command zone cannot be set up in this harness —
    createCommanderEffect runs during game start, which the test harness skips — so that reasoning
    is a code read.

🤖 Implemented with the assistance of Claude Code (Opus 5).

@liamiak liamiak changed the title Only let a card be cast from its owner's hand, library or graveyard Only let a card be cast from its owner's hand or graveyard Jul 26, 2026
@liamiak
liamiak force-pushed the fix-flashback-opponent-graveyard branch from f054a5f to efb832c Compare July 26, 2026 02:57
@tool4ever

Copy link
Copy Markdown
Contributor

don't really want more workarounds in this area, try researching why the check that prevents it had to be limited to Desktop: 07e4109

liamiak1 and others added 3 commits July 30, 2026 20:23
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>
@liamiak
liamiak force-pushed the fix-flashback-opponent-graveyard branch from efb832c to 8caf6e6 Compare July 31, 2026 03:18
@liamiak

liamiak commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Researched it. The short version is that the check you pointed me at is not a check that needs
un-limiting — it is the rules layer's job done in the GUI, and once it moves the GUI copy can go.
So this is now a net removal rather than another workaround. It also turned up two things I had
wrong, both below.

Why it was limited to Desktop

07e4109 added triggerEvent != null so the guard would stop firing on the PlayEffect path. The
two-arg PlayerController.getAbilityToPlay(hostCard, abilities) passes null for the trigger
event, and that overload is what PlayEffect, DiscoverEffect, ChangeZoneEffect and
PlaySpellAbility all call. So the gate is really asking "did a human click this, or is an effect
offering it?", with the mouse event standing in for the answer.

That proxy is what confines it to Desktop. ITriggerEvent is a mouse event: the mobile GUI never
produces one, and RemoteClientGuiGame deliberately sends null

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
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".

The proxy cannot be repaired in place, because a UI controller has no way to tell permission from a
click. The restriction layer can: every grant path already clears the zone before the check runs.
So the guard is gone in the last commit, and checkZoneRestrictions now says the whole rule in one
condition — a zone restriction means the activator's own zone.

InputPassPriority asks the game state before it ever reaches getAbilityToPlay and bails on an
empty list. On a click removeUnplayable is false, so unplayable abilities would normally be kept
and greyed out, but both deciders route back through the same restrictions — spells take
isPossible()'s default of canPlay(), and the one override, AbilityActivated.isPossible(),
returns checkZoneRestrictions(...) && checkActivatorRestrictions(...). Measured on that call:

Card in the owner's zone owner other
Geistflame in the graveyard (flashback, a spell) 1 0
Abzan Devotee in the graveyard (activated ability) 1 0
Lightning Bolt in the hand (a plain spell) 1 0

Both GUIs share that call site — forge-gui-mobile has no ability lookup of its own — so this is
also what closes the mobile hole.

Two things I had wrong

Shaman's Trance. It grants a keyword rather than a MayPlay, and the two sites in
StaticAbilityContinuous that honour it only widen existing MayPlay statics; flashback is an
alternative cost, so neither covers it. The version you reviewed refused it. Worth noting the
baseline cannot tell that case from the bug, which is how it slipped past:

non-owner, no Trance non-owner, after Trance
before this PR 1 (the bug) 1
as reviewed 0 0 ← broken
now 0 1

The old description claimed no spell is ever restricted to a zone other than hand or graveyard.
That is false — foretell and plot set Exile. They guard themselves with
activator.equals(source.getOwner()) when the ability is built, so they were never the bug, but
the claim was wrong and the enumeration it justified is gone. The check no longer names any zone.

Tests now cover each zone a spell can be restricted to: graveyard (owner, non-owner, the granted
DB$ Play path, Trance) and exile (a foretold card). 288 tests, 0 failures.

Two limits I would rather state than paper over. Nothing here exercises the human GUI, so the guard
removal rests on the measurements above rather than on a test — it is the last commit and drops
cleanly if you would rather keep the guard. And the command zone cannot be set up in the test
harness, since createCommanderEffect runs during game start; it is inert for this check anyway,
because commander casting is a MayPlay grant scoped to Card.IsCommander+YouOwn, so its zone is
nulled before the check sees it.

@tool4ever tool4ever left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well now I can cast any spell from my opponents hand with As Foretold...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to flashback a card from opponent's graveyard

3 participants