fix(engine): Bring to Light free-cast is resolution-only, not a lingering permission (#2880) - #3153
Conversation
…ring permission (#2880)
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
[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.
| 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
- 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)
Summary
Closes #2880.
Bring to Light's "You may cast that card without paying its mana cost" granted an indefinite
ExileWithAltCostpermission, 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:
oracle_effect/mod.rs, Branch-1ParentTargetanaphor arm only): emitCastFromZoneDriver::DuringResolutionfor a bare free cast (mode == Cast && without_paying && alt_ability_cost.is_none() && duration.is_none() && constraint.is_none()), elseLingeringPermission.cast_from_zone.rs): generalized theself_free_castguard (renameddriver_free_cast) by dropping the Suspend-specifictarget == sourceclause, so a tutored card (target ≠ source, in the controller's own exile) routes throughcast_single_target_during_resolutionlike Suspend/Memory Plunder.Deliberately excluded (stay on the lingering-permission path)
mode == Play; land play is a special action (CR 305.1), not a during-resolution cast.alt_ability_cost.duration.constraint; the constraint is evaluated when the permission is exercised and the fallback depends on the standing permission.from among them/ExiledBySourcearms — untouched.the copy(Isochron Scepter, Elite Arcanist) — folds toCastCopyOfCardbefore reaching the resolver; driver irrelevant.Tests
New
bring_to_light_free_cast_2880.rsdrives 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-enginegreen (including the Beseechconstraint-path regression guardbeseech_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).