Skip to content

fix(engine): Bring to Light free-cast is resolution-only, not a lingering permission (#2880) - #3153

Merged
matthewevans merged 1 commit into
mainfrom
fix/issue-2880-bring-to-light-free-cast
Jun 13, 2026
Merged

fix(engine): Bring to Light free-cast is resolution-only, not a lingering permission (#2880)#3153
matthewevans merged 1 commit into
mainfrom
fix/issue-2880-bring-to-light-free-cast

Conversation

@matthewevans

Copy link
Copy Markdown
Member

Summary

Closes #2880.

Bring to Light's "You may cast that card without paying its mana cost" granted an indefinite ExileWithAltCost permission, so the free cast could be taken at any later priority for the rest of the game. Per CR 608.2g the cast happens during the spell's resolution; the permission must not linger.

This is a class fix for the resolution-only free-cast family (~28 Cast-mode cards), root-caused in two layers:

  • Parser (oracle_effect/mod.rs, Branch-1 ParentTarget anaphor arm only): emit CastFromZoneDriver::DuringResolution for a bare free cast (mode == Cast && without_paying && alt_ability_cost.is_none() && duration.is_none() && constraint.is_none()), else LingeringPermission.
  • Engine (cast_from_zone.rs): generalized the self_free_cast guard (renamed driver_free_cast) by dropping the Suspend-specific target == source clause, so a tutored card (target ≠ source, in the controller's own exile) routes through cast_single_target_during_resolution like Suspend/Memory Plunder.

Deliberately excluded (stay on the lingering-permission path)

  • Play-mode hideaway/wish lands (Mosswort Bridge, Windbrisk Heights, Shelldock Isle, Djinn of Wishes…) — mode == Play; land play is a special action (CR 305.1), not a during-resolution cast.
  • Alternative-cost recasts (The Infamous Cruelclaw's discard) — alt_ability_cost.
  • Durational recasts — explicit duration.
  • Constrained free-casts with a not-cast fallback (Beseech the Mirror: "cast the exiled card … if that spell's mana value is 4 or less … put it into your hand if it wasn't cast this way") — constraint; the constraint is evaluated when the permission is exercised and the fallback depends on the standing permission.
  • Cascade/Etali/Jeleva from among them / ExiledBySource arms — untouched.
  • the copy (Isochron Scepter, Elite Arcanist) — folds to CastCopyOfCard before reaching the resolver; driver irrelevant.

Tests

New bring_to_light_free_cast_2880.rs drives the real parser+resolver pipeline: accept → tutored card reaches the stack and leaves no standing permission; decline → stays in exile with no lingering permission. Both fail on revert.

Verification

Local Tilt clippy + test-engine green (including the Beseech constraint-path regression guard beseech_bargained_accept_grants_cast_without_hand_fallback). Independent /review-impl: APPROVED.

CR: 608.2g, 608.2c, 118.9/118.9b, 305.1, 701.23 (all grep-verified).

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request resolves issue #2880 by refactoring the DuringResolution cast-from-zone mechanism to support tutor-and-cast effects like Bring to Light, where the target card is not the source of the ability. The Suspend-specific target == source check has been removed, and a new integration test has been added to ensure no lingering casting permissions remain after resolution. The review feedback recommends adding defensive validation to return an error if a DuringResolution driver is specified but its structural invariants are not met, preventing a silent fallback to indefinite lingering permissions.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +218 to +221
let driver_free_cast = driver.is_during_resolution()
&& without_paying
&& alt_ability_cost.is_none()
&& target_ids.len() == 1
&& target_ids[0] == ability.source_id;
&& target_ids.len() == 1;

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.

medium

[MEDIUM] Prevent Silent Fallback to Indefinite Lingering Permissions

If a future card or effect uses the DuringResolution driver but fails any of the invariants (e.g., having multiple targets or an alternative cost), driver_free_cast will evaluate to false.

Currently, this causes the execution to silently fall through to grant_lingering_permissions. Because DuringResolution effects do not have a duration, this fall-through will stamp an indefinite ExileWithAltCost permission, allowing the player to cast the card at any later point in the game. This is a severe violation of CR 608.2g.

To enforce defensive programming and strict fidelity to the MTG Comprehensive Rules, we should explicitly return an error if the DuringResolution driver is specified but its structural invariants are not met.

Suggested change
let driver_free_cast = driver.is_during_resolution()
&& without_paying
&& alt_ability_cost.is_none()
&& target_ids.len() == 1
&& target_ids[0] == ability.source_id;
&& target_ids.len() == 1;
let driver_free_cast = driver.is_during_resolution()
&& without_paying
&& alt_ability_cost.is_none()
&& target_ids.len() == 1;
if driver.is_during_resolution() && !driver_free_cast {
return Err(EffectError::InvalidParam(
"DuringResolution driver requires exactly one target and no alternative cost".to_string(),
));
}
References
  1. Strict fidelity to the MTG Comprehensive Rules (CR) — every game rule, validation, and computed value matches the CR exactly. Ensure that invalid inputs or states are safely handled in all cases. (link)

@matthewevans
matthewevans added this pull request to the merge queue Jun 13, 2026
Merged via the queue into main with commit b19db36 Jun 13, 2026
10 checks passed
@matthewevans
matthewevans deleted the fix/issue-2880-bring-to-light-free-cast branch June 13, 2026 17:56
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.

Bring to Light — free-cast permission persists indefinitely instead of only at resolution

1 participant