diff --git a/crates/engine/data/known-tokens.toml b/crates/engine/data/known-tokens.toml index de74543516..438a89f4b2 100644 --- a/crates/engine/data/known-tokens.toml +++ b/crates/engine/data/known-tokens.toml @@ -386,11 +386,9 @@ source_card_names = [ "Bank Job", "Battle Angels of Tyr", "Beamtown Beatstick", - "Bejeweled Warg", "Beza, the Bounding Spring", "Big Score", "Big Spender", - "Bilbo's Gambit", "Bilbo, Retired Burglar", "Bill Ferny, Bree Swindler", "Black Market Connections", @@ -449,8 +447,6 @@ source_card_names = [ "Dockside Extortionist", "Don Andres, the Renegade", "Done for the Day", - "Dori, Bearer of Friends", - "Dragon-Cursed Halls", "Dungeon of the Mad Mage", "Dungeoneer's Pack", "Edward Kenway", @@ -483,7 +479,6 @@ source_card_names = [ "Gilded Pinions", "Gimli of the Glittering Caves", "Gleaming Barrier", - "Gleaming Splendor", "Glittermonger", "Gluntch, the Bestower", "Glóin, Dwarf Emissary", @@ -549,7 +544,6 @@ source_card_names = [ "Life Insurance", "Lobelia Sackville-Baggins", "Locke, Treasure Hunter", - "Long-Bodied Grey Dog", "Loot Dispute", "Lost Mine of Phandelver", "Lotho, Corrupt Shirriff", @@ -592,7 +586,6 @@ source_card_names = [ "Old Gnawbone", "Old Rutstein", "Olivia, Opulent Outlaw", - "Orcrist, Goblin-cleaver", "Orochi Soul-Reaver", "Pain Distributor", "Patient Naturalist", @@ -660,9 +653,6 @@ source_card_names = [ "Skullport Merchant", "Smashing Success", "Smaug", - "Smaug the Impenetrable", - "Smaug the Magnificent", - "Smaug, Wicked Worm", "Smoke Blessing", "Smoke Spirits' Aid", "Smothering Tithe", @@ -696,15 +686,11 @@ source_card_names = [ "The Gold Saucer", "The Golden City of Orazca", "The Matrix of Time", - "The Misty Mountains Cold", "The Reaver Cleaver", - "The Sackville-Bagginses", "The Third Doctor", "The Western Cloud", "There and Back Again", "Thieves' Tools", - "Thorin, Company's Leader", - "Thorin, King of Durin's Folk", "Ticket Tortoise", "Tireless Provisioner", "Tivit, Seller of Secrets", diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index f49fd1b80c..f3a7371641 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -911,6 +911,25 @@ fn restriction_scope_matches_player( RestrictionPlayerScope::OpponentsOfSourceController => { source_controller.is_some_and(|controller| controller != caster) } + // CR 109.5 + CR 611.2a: the affected "you" ("you can't cast additional + // spells this turn" — Conduit of Worlds) is the player who activated the + // ability (CR 109.5: an activated ability's "you" is the activator), fixed + // at resolution, and the resulting continuous effect lasts until end of + // turn independent of its source (CR 611.2a). `add_restriction` lowers + // `SourceController` to `SpecificPlayer` at creation so the ban stays with + // the activator even after the source leaves play or changes controller — + // reading it live here would silently drop the ban when the source is + // gone (`source_controller == None`). An unresolved scope here is a bug: + // a corrupt/forged snapshot is scrubbed of it on restore by + // `GameState::drop_unresolved_source_controller_restrictions`, so a raw + // scope reaching this arm means the invariant was violated in a live state. + RestrictionPlayerScope::SourceController => { + debug_assert!( + false, + "SourceController should be resolved by add_restriction" + ); + false + } } } diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 53d33592ba..fcfe846c90 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -9694,6 +9694,23 @@ fn finalize_cast_with_phyrexian_choices_inner( object_id, }); + // CR 608.2c + CR 608.2g + CR 601.2i: A paid during-resolution cast is the + // "performed optional" the moment its spell is on the stack and its mana is + // paid — this line is reached only on full payment completion (a pause + // returns earlier, and a cancelled/rewound cast never emits SpellCast), so an + // unpayable or declined cast never latches. When the granting ability parked + // an "If you do, …" rider as a continuation (Conduit of Worlds: "you may cast + // that card. If you do, you can't cast additional spells this turn."), that + // rider's `EffectOutcome { OptionalEffectPerformed }` gate must now evaluate + // true. Mark only the first causal gate in source order: a later independent + // optional cast must remain unperformed until its own cast commits. + if let Some(frame) = state.active_ability_continuation_frame_mut() { + frame + .pending + .chain + .set_first_optional_effect_performed_gate(true); + } + // CR 601.2a + CR 601.2b + CR 110.4: Record permission usage when the spell // is finalized onto the stack. This prevents casting a second spell via the // same source/slot before the first resolves. Only frequency-bounded diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 6544ff0e5a..2b3a34402a 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -28623,6 +28623,73 @@ fn cast_only_from_zones_allows_hand_casts_for_affected_player() { )); } +#[test] +fn source_controller_scope_is_locked_to_activator_not_source() { + // CR 109.5 + CR 611.2a: a `CastSpells` prohibition scoped to + // `SourceController` (Conduit of Worlds' "you can't cast additional spells + // this turn") comes from the resolution of an ACTIVATED ability, so "you" is + // the player who activated it (CR 109.5), fixed at resolution. The resulting + // rules-modifying continuous effect lasts until end of turn (CR 611.2a) — a + // source-independent turn-based duration — so it must keep affecting the + // original activator even after the source changes controller or leaves play. + // `add_restriction` lowers `SourceController` to `SpecificPlayer` at creation + // to lock that activator. + let mut state = setup_game_at_main_phase(); + let next_id = state.next_object_id; + let source = create_object( + &mut state, + CardId(next_id), + PlayerId(0), + "Conduit of Worlds".to_string(), + Zone::Battlefield, + ); + + // Resolve the rider through add_restriction so the scope is lowered exactly + // as it is in play (P0 is the activator/controller). + let ability = ResolvedAbility::new( + Effect::AddRestriction { + restriction: GameRestriction::ProhibitActivity { + source: ObjectId(0), + affected_players: RestrictionPlayerScope::SourceController, + expiry: RestrictionExpiry::EndOfTurn, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + }, + }, + vec![], + source, + PlayerId(0), + ); + let mut events = Vec::new(); + crate::game::effects::add_restriction::resolve(&mut state, &ability, &mut events).unwrap(); + + // The scope was lowered to the activator, not left as a live `SourceController`. + assert!(matches!( + &state.restrictions[0], + GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SpecificPlayer(PlayerId(0)), + .. + } + )); + + // The activator (P0) is banned; the opponent (P1) is not. + assert!(is_blocked_by_cant_cast_spells(&state, PlayerId(0), None)); + assert!(!is_blocked_by_cant_cast_spells(&state, PlayerId(1), None)); + + // CR 109.5: the "you" is the activator, fixed at resolution. Changing the + // source's controller does NOT move the ban to the new controller. + state.objects.get_mut(&source).unwrap().controller = PlayerId(1); + assert!(is_blocked_by_cant_cast_spells(&state, PlayerId(0), None)); + assert!(!is_blocked_by_cant_cast_spells(&state, PlayerId(1), None)); + + // CR 611.2a: the effect also survives the source leaving play — its + // turn-based duration is independent of the source. Conduit is a fragile 1/1 + // artifact creature that can be sacrificed / bounced + // / killed the same turn, but the ban on the activator persists this turn. + state.objects.remove(&source); + assert!(is_blocked_by_cant_cast_spells(&state, PlayerId(0), None)); + assert!(!is_blocked_by_cant_cast_spells(&state, PlayerId(1), None)); +} + #[test] fn creature_in_hand_castable_with_untapped_lands() { use crate::ai_support::{candidate_actions, legal_actions}; @@ -50270,6 +50337,69 @@ fn graveyard_paid_cast_router_opens_offer_not_lingering_permission() { ); } +#[test] +fn paid_during_resolution_cast_router_is_independent_of_chosen_card_zone() { + for zone in [Zone::Hand, Zone::Exile, Zone::Library] { + let mut state = setup_game_at_main_phase(); + let spell = make_graveyard_blue_sorcery(&mut state, PlayerId(0)); + state.objects.get_mut(&spell).expect("spell exists").zone = zone; + + resolve_graveyard_paid_grant(&mut state, spell); + + assert!( + matches!( + &state.waiting_for, + WaitingFor::CastOffer { + kind: crate::types::game_state::CastOfferKind::GraveyardPaidCast { + hit_card, + .. + }, + .. + } if *hit_card == spell + ), + "a paid during-resolution cast from {zone:?} must open the one-shot offer; got {:?}", + state.waiting_for + ); + assert!( + state.objects[&spell].casting_permissions.is_empty(), + "a no-duration cast from {zone:?} must not become a lingering permission" + ); + } +} + +#[test] +fn paid_cast_with_explicit_duration_remains_a_lingering_permission() { + let mut state = setup_game_at_main_phase(); + let spell = make_graveyard_blue_sorcery(&mut state, PlayerId(0)); + let grant = ResolvedAbility::new( + Effect::CastFromZone { + target: TargetFilter::ParentTarget, + without_paying_mana_cost: false, + mode: CardPlayMode::Cast, + cast_transformed: false, + alt_ability_cost: None, + constraint: None, + duration: Some(Duration::UntilEndOfTurn), + driver: crate::types::ability::CastFromZoneDriver::DuringResolution, + mana_spend_permission: None, + }, + vec![TargetRef::Object(spell)], + ObjectId(9200), + PlayerId(0), + ); + + crate::game::effects::cast_from_zone::resolve(&mut state, &grant, &mut Vec::new()).unwrap(); + + assert!( + !matches!(state.waiting_for, WaitingFor::CastOffer { .. }), + "an explicit duration must prevent a one-shot resolution offer" + ); + assert!( + !state.objects[&spell].casting_permissions.is_empty(), + "the duration-bearing permission must remain available after resolution" + ); +} + #[test] fn graveyard_paid_manual_cast_remains_offered_and_reaches_mana_payment() { let mut state = setup_game_at_main_phase(); @@ -50609,6 +50739,200 @@ fn free_during_resolution_cast_auto_resolves_with_empty_pool() { ); } +// --- Conduit of Worlds line-2 end-to-end (Steps 5/6/7) -------------------- + +/// Conduit of Worlds' `{T}` ability, verbatim minus the (separately-tested) line +/// 1 static, built with ONLY the activated ability so `ability_index == 0` is +/// unambiguous. +const CONDUIT_LINE2: &str = "{T}: Choose target nonland permanent card in your graveyard. If you haven't cast a spell this turn, you may cast that card. If you do, you can't cast additional spells this turn. Activate only as a sorcery."; + +/// Build a scenario with Conduit's `{T}` ability on the battlefield for P0 +/// (untapped, no summoning sickness), a `{1}` creature card in P0's graveyard, +/// and a green mana pool that covers the graveyard card's cost. Returns the +/// runner, Conduit's id, and the graveyard creature's id. +fn setup_conduit_activation() -> (crate::game::scenario::GameRunner, ObjectId, ObjectId) { + let mut scenario = crate::game::scenario::GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + PlayerId(0), + (0..3) + .map(|_| ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![])) + .collect(), + ); + let conduit = { + let mut b = scenario.add_creature(PlayerId(0), "Conduit of Worlds", 1, 1); + b.from_oracle_text(CONDUIT_LINE2); + b.id() + }; + let gy_creature = { + let mut b = scenario.add_creature_to_graveyard(PlayerId(0), "Grave Bear", 2, 2); + b.with_mana_cost(ManaCost::generic(1)); + b.id() + }; + let runner = scenario.build(); + (runner, conduit, gy_creature) +} + +/// Drive Conduit's `{T}` ability from activation through resolution up to the +/// `GraveyardPaidCast` offer, targeting `gy_creature`. Asserts the offer opens +/// (the Step 5/6 reach-guard: without the paid during-resolution driver + the +/// relaxed gate, no such offer is presented). +fn activate_conduit_to_offer( + runner: &mut crate::game::scenario::GameRunner, + conduit: ObjectId, + gy_creature: ObjectId, +) { + runner + .act(GameAction::ActivateAbility { + source_id: conduit, + ability_index: 0, + }) + .expect("activating Conduit's {T} ability must succeed"); + for _ in 0..16 { + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + kind: crate::types::game_state::CastOfferKind::GraveyardPaidCast { hit_card, .. }, + .. + } => { + assert_eq!(hit_card, gy_creature, "offer must target the chosen card"); + return; + } + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(gy_creature)), + }) + .expect("choosing the graveyard target must succeed"); + } + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("passing priority to resolve the ability must succeed"); + } + other => panic!("unexpected pre-offer waiting_for: {other:?}"), + } + } + panic!("Conduit activation never reached the GraveyardPaidCast offer"); +} + +fn has_source_controller_cant_cast(state: &GameState) -> bool { + // CR 109.5: Conduit's self-scoped "you can't cast additional + // spells this turn" rider is lowered to `SpecificPlayer(activator)` at + // creation (the activator is PlayerId(0) in this fixture), so the installed + // ban carries that concrete player — not the parse-time `SourceController` + // scope, which is never stored. + state.restrictions.iter().any(|r| { + matches!( + r, + GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SpecificPlayer(PlayerId(0)), + activity: ProhibitedActivity::CastSpells { .. }, + .. + } + ) + }) +} + +/// CR 608.2g + CR 608.2c + CR 601.2i (Steps 5/6/7): accepting Conduit's paid +/// during-resolution graveyard cast and completing payment casts the targeted +/// card (it leaves the graveyard onto the stack) AND — because the cast +/// committed — latches the "If you do" rider, installing the self-scoped +/// `SourceController` cast-ban. The rider must NOT be installed before the cast +/// commits. +#[test] +fn conduit_accept_commits_cast_and_installs_self_cast_ban() { + let (mut runner, conduit, gy_creature) = setup_conduit_activation(); + activate_conduit_to_offer(&mut runner, conduit, gy_creature); + + // Positive reach-guard: the offer is open (Step 6). The ban is NOT yet + // installed — the rider latches only on commit, not at offer time. + assert!( + !has_source_controller_cant_cast(runner.state()), + "the self cast-ban must not be installed before the paid cast commits" + ); + + runner + .act(GameAction::GraveyardPaidCastChoice { + choice: crate::types::actions::CastChoice::Cast, + }) + .expect("accepting the paid cast must succeed"); + assert!( + matches!(runner.state().waiting_for, WaitingFor::ManaPayment { .. }), + "FullCost accept must open a manual mana payment, got {:?}", + runner.state().waiting_for + ); + + // Finalize the {1} payment from the pool: the card commits to the stack. + runner + .act(GameAction::PassPriority) + .expect("finalizing the payment must succeed"); + assert!( + runner + .state() + .stack + .iter() + .any(|e| e.source_id == gy_creature), + "the paid cast card must commit to the stack" + ); + assert_ne!( + runner.state().objects[&gy_creature].zone, + Zone::Graveyard, + "the cast card must have left the graveyard" + ); + + // CR 608.2g: pass priority so the paid cast resolves and Conduit's stashed + // continuation resumes, firing the commit-latched "if you do" rider. Stop as + // soon as the ban is installed — passing beyond the turn boundary would prune + // the EndOfTurn restriction. + for _ in 0..8 { + if has_source_controller_cant_cast(runner.state()) { + break; + } + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + assert!( + has_source_controller_cant_cast(runner.state()), + "committing the paid cast must install the 'you can't cast additional spells' rider" + ); +} + +/// CR 608.2c + CR 608.2g (Step 7 hostile fixture): declining the offer casts +/// nothing and — because the optional cast was NOT performed — leaves the "If you +/// do" rider un-fired, so no self cast-ban is installed and the card stays in the +/// graveyard. Paired with the accept test above, this proves the rider is gated +/// on the cast actually committing, not on merely reaching the offer. +#[test] +fn conduit_decline_casts_nothing_and_installs_no_ban() { + let (mut runner, conduit, gy_creature) = setup_conduit_activation(); + activate_conduit_to_offer(&mut runner, conduit, gy_creature); + + runner + .act(GameAction::GraveyardPaidCastChoice { + choice: crate::types::actions::CastChoice::Decline, + }) + .expect("declining the paid cast must succeed"); + + assert_eq!( + runner.state().objects[&gy_creature].zone, + Zone::Graveyard, + "declining must leave the card in the graveyard" + ); + assert!( + !runner + .state() + .stack + .iter() + .any(|e| e.source_id == gy_creature), + "declining must not put the card on the stack" + ); + assert!( + !has_source_controller_cant_cast(runner.state()), + "declining must not install the self cast-ban (the rider's 'if you do' is false)" + ); +} + /// CR 601.2a + CR 701.27: the exact during-resolution grant controls whether /// a transforming double-faced card is cast transformed. A compatible older /// sibling with `cast_transformed: true` must not transform an offer whose diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index 7b585c0fb8..bd8494ba74 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -3921,11 +3921,20 @@ fn attack_passes_temporary_prohibition( crate::types::ability::RestrictionPlayerScope::OpponentsOfSourceController => { attacker_controller != protected } + // CR 109.5 + CR 611.2c: `SourceController` (the "you" in a "you can't + // attack" rider) is lowered to `SpecificPlayer(original_controller)` + // by `add_restriction` at creation, so it is enforced by the + // `SpecificPlayer` arm above and never reaches here as a raw scope — + // the same lower-at-creation contract as the sibling placeholder + // scopes below. A corrupt/forged snapshot is additionally scrubbed of + // any raw `SourceController` restriction on restore by + // `GameState::drop_unresolved_source_controller_restrictions`. crate::types::ability::RestrictionPlayerScope::TargetedPlayer | crate::types::ability::RestrictionPlayerScope::ParentTargetedPlayer | crate::types::ability::RestrictionPlayerScope::DefendingPlayer | crate::types::ability::RestrictionPlayerScope::ParentObjectTargetController - | crate::types::ability::RestrictionPlayerScope::ScopedPlayer => false, + | crate::types::ability::RestrictionPlayerScope::ScopedPlayer + | crate::types::ability::RestrictionPlayerScope::SourceController => false, }; if !attacker_is_affected { continue; diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 76472cdb1f..a7b294e186 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -2148,10 +2148,14 @@ fn restriction_affected_players( // restriction never carries an unresolved placeholder scope here. // CR 109.4: `ParentObjectTargetController` is likewise resolved to // `SpecificPlayer` by `add_restriction` at creation time. + // CR 611.2c: `SourceController` (Conduit of Worlds' "you") is likewise + // lowered to `SpecificPlayer(original_controller)` at creation so the + // affected player stays the activator independently of the source. RestrictionPlayerScope::TargetedPlayer | RestrictionPlayerScope::ParentTargetedPlayer | RestrictionPlayerScope::ParentObjectTargetController - | RestrictionPlayerScope::ScopedPlayer => Vec::new(), + | RestrictionPlayerScope::ScopedPlayer + | RestrictionPlayerScope::SourceController => Vec::new(), // CR 508.5a: `add_restriction` resolves the defending player to // `SpecificPlayer` when the restriction is created, so a stored // restriction never carries an unresolved `DefendingPlayer` scope here. diff --git a/crates/engine/src/game/effects/add_restriction.rs b/crates/engine/src/game/effects/add_restriction.rs index 3f88b84ab5..06a5c79d0e 100644 --- a/crates/engine/src/game/effects/add_restriction.rs +++ b/crates/engine/src/game/effects/add_restriction.rs @@ -161,6 +161,19 @@ fn fill_runtime_fields( *affected_players = RestrictionPlayerScope::SpecificPlayer(controller); } } + // CR 109.5 + CR 611.2a: `SourceController` (the "you" in "you + // can't cast additional spells this turn" — Conduit of Worlds) + // comes from the resolution of an ACTIVATED ability, so "you" is + // the player who activated it (CR 109.5), fixed at resolution. The + // resulting rules-modifying continuous effect lasts until end of + // turn (CR 611.2a), a source-independent turn-based duration: it + // must keep affecting the original activator even if the source + // later changes controller or leaves play. Lower to + // `SpecificPlayer` now to lock that player, exactly like the other + // affected-player scopes above. + RestrictionPlayerScope::SourceController => { + *affected_players = RestrictionPlayerScope::SpecificPlayer(original_controller); + } RestrictionPlayerScope::AllPlayers | RestrictionPlayerScope::SpecificPlayer(_) | RestrictionPlayerScope::OpponentsOfSourceController => {} @@ -197,6 +210,13 @@ fn fill_runtime_fields( // controller's. The affected-player resolution above already lowered a // `TargetedPlayer`/`ParentTargetedPlayer` scope to `SpecificPlayer(p)`, // so read that resolved player here (Willie Lumpkin). + // CR 109.5: this block only anchors "during their next turn"-style + // expiries on the RESTRICTED player. `SourceController` is lowered to + // `SpecificPlayer(original_controller)` by the affected-player + // resolution above (Conduit of Worlds' "you"), so it is read here via + // the `SpecificPlayer` arm — a future `SourceController` restriction + // carrying a next-turn duration correctly anchors on the activator + // with no special-casing. let restricted_player = match affected_players { RestrictionPlayerScope::SpecificPlayer(p) => Some(*p), _ => None, diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index e98ff93407..d8b7403120 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -731,24 +731,39 @@ pub fn resolve( .get(&target_ids[0]) .is_some_and(|obj| obj.zone == Zone::Graveyard); - // CR 608.2g + CR 609.4b: paid during-resolution graveyard cast (Quistis Trepe, - // Tinybones the Pickpocket). Not without_paying — the caster pays the real cost - // with any-type mana. Offered accept/decline, resolved by - // initiate_cast_during_resolution with ResolutionCastCost::FullCost. Replaces - // the wrong lingering-permission path (#2884: the offer was inert on - // opponent-graveyard targets, and own-graveyard targets deferred the cast to a - // later priority window instead of a resolution-time offer). - let graveyard_paid_cast = !without_paying - && mana_spend_permission.is_some() + // CR 608.2g + CR 609.4b: paid during-resolution cast of a CHOSEN target. The + // caster pays the real printed cost as the granting ability resolves; the mana + // is any-type when `mana_spend_permission` is `Some` (Quistis Trepe, Tinybones + // the Pickpocket) and NORMAL mana at the printed cost when it is `None` + // (Conduit of Worlds: "Choose target nonland permanent card in your graveyard + // … you may cast that card."). Both thread `ResolutionCastCost::FullCost` + // through `initiate_cast_during_resolution`, which defaults a `None` + // permission to normal mana. Offered accept/decline. Replaces the wrong + // lingering-permission path (#2884: the offer was inert on opponent-graveyard + // targets, and own-graveyard targets deferred the cast to a later priority + // window instead of a resolution-time offer). + // + // CR 608.2g: during-resolution timing is a property of the resolving + // INSTRUCTION (the `DuringResolution` driver, set by the parser from a paid + // chosen-target "you may cast that card" with no lingering duration), NOT of + // the chosen card's zone. This gate therefore accepts any castable + // non-battlefield origin — graveyard (Conduit), hand, exile, or library — rather + // than requiring `Zone::Graveyard`; `initiate_cast_during_resolution` casts + // the card from whichever zone it currently occupies. Emry's "you may cast + // that card THIS TURN" carries `duration: Some(_)` and is lowered to + // `LingeringPermission` by the parser, so it never reaches this branch. + let paid_during_resolution_cast = !without_paying && driver.is_during_resolution() && alt_ability_cost.is_none() && duration.is_none() && target_ids.len() == 1 - && state - .objects - .get(&target_ids[0]) - .is_some_and(|o| o.zone == Zone::Graveyard); - if graveyard_paid_cast { + && state.objects.get(&target_ids[0]).is_some_and(|o| { + matches!( + o.zone, + Zone::Graveyard | Zone::Hand | Zone::Exile | Zone::Library + ) + }); + if paid_during_resolution_cast { events.push(GameEvent::EffectResolved { kind: EffectKind::CastFromZone, source_id: ability.source_id, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index dd969b5032..9abbcd5852 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3019,6 +3019,19 @@ fn effect_manages_own_outcome_flag(effect: &Effect) -> bool { // (whiff → `cost_payment_failed_flag`; beheld → the rider fires), so // the mandatory-rider seed must not race it. | Effect::Behold { .. } + // CR 608.2g + CR 608.2c: a paid during-resolution graveyard cast + // (Conduit of Worlds) is lowered mandatory because the interactive + // `GraveyardPaidCast` offer IS its "you may". Its "if you do" outcome + // is decided by that offer — the commit-point latch + // (casting_costs.rs) sets the flag only when the cast actually + // commits, and a decline leaves it false. The mandatory-rider seed + // must not pre-set the flag, or a declined offer would wrongly fire + // the "you can't cast additional spells this turn" rider. + | Effect::CastFromZone { + driver: crate::types::ability::CastFromZoneDriver::DuringResolution, + without_paying_mana_cost: false, + .. + } ) } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 93ba87bcbf..05d4ee5db6 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15991,13 +15991,12 @@ mod stage2_injector_tests { // `:6210/:6287/:9475 => :6212/:6289/:9477`. The producers remain byte-identical. // #7018 adds the 16-line distinct-player-scope continuation gate above all // three producers: `:6212/:6289/:9477 => :6228/:6305/:9493`. - // Main's three-frame debug-entry resumer shifts all three by +3. - // #6938 adds five counter-reproduction lines above the first two - // and five event-batch carry-through lines before the third; none - // creates an `OptionalEffect` prompt. - "game/effects/mod.rs:6236".to_string(), - "game/effects/mod.rs:6313".to_string(), - "game/effects/mod.rs:9506".to_string(), + // Main's debug-entry (+3) and counter-reproduction (+5/+10) + // shifts combine with #6958's paid-cast outcome exclusion (+13). + // None creates an `OptionalEffect` prompt. + "game/effects/mod.rs:6249".to_string(), + "game/effects/mod.rs:6326".to_string(), + "game/effects/mod.rs:9519".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 183c9c9fc3..50fa7c70b9 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -3962,6 +3962,11 @@ fn try_parse_cant_cast_spells_effect(tp: TextPair<'_>) -> Option) -> Option Option ParsedEffectCl if clause.duration.is_none() { clause.duration = duration; } + // CR 611.2a: A during-resolution cast happens AS the ability resolves and + // cannot carry a lingering play-window duration. When the anaphor branch set + // the paid graveyard "cast that card" driver to `DuringResolution` from the + // chosen target's zone alone, but a trailing duration was stripped above + // ("cast that card THIS TURN" — Emry, Lurker in the Loch), this is really a + // standing `LingeringPermission` grant — restore it. The optionality + // reconciliation for the no-duration paid case (clearing the redundant + // `OptionalEffectChoice`) happens at the chunk-level `is_optional` derivation, + // where the offer's "may" is recognized (mirroring `FreeCastFromZones`). + if clause.duration.is_some() { + if let Effect::CastFromZone { + driver: driver @ crate::types::ability::CastFromZoneDriver::DuringResolution, + without_paying_mana_cost: false, + .. + } = &mut clause.effect + { + *driver = crate::types::ability::CastFromZoneDriver::LingeringPermission; + } + } // CR 115.1d: Post-parse fixup for the "…counter(s) on up to N target …" shape. // The multi_target is lost in the AST→Effect lowering chain, so we re-extract // it from the original text. `PutCounter` and `ReproduceEventCounters` share @@ -20014,32 +20055,38 @@ fn rebind_reanimate_animation_until_leaves(def: &mut AbilityDefinition) { } } -/// CR 608.2c + CR 601.2a: Does the chain's prior referent come from an explicit -/// target SELECTION (`Effect::TargetOnly`) rather than an exile/impulse publisher -/// (`ExileTop`, `ExileFromTopUntil`, `ChangeZone`, token creation)? Emry, Lurker -/// in the Loch — "Choose target artifact card in your graveyard. You may cast -/// that card this turn." — selects its referent, so the anaphor binds to that -/// chosen card (`CastFromZone { ParentTarget }`). An impulse publisher's anaphor -/// is the tracked exile set and must stay a `PlayFromExile { TrackedSet }` grant +/// CR 608.2c + CR 601.2a: Returns the chain's prior chosen-target FILTER when the +/// prior referent comes from an explicit target SELECTION (`Effect::TargetOnly`) +/// rather than an exile/impulse publisher (`ExileTop`, `ExileFromTopUntil`, +/// `ChangeZone`, token creation). Emry, Lurker in the Loch — "Choose target +/// artifact card in your graveyard. You may cast that card this turn." — selects +/// its referent, so the anaphor binds to that chosen card +/// (`CastFromZone { ParentTarget }`). An impulse publisher's anaphor is the +/// tracked exile set and must stay a `PlayFromExile { TrackedSet }` grant /// (Territorial Bruntar's `ExileFromTopUntil` referent is whitelisted by /// `has_typed_target_widened`, so this stricter check is what excludes it). The /// walk mirrors `chain_has_prior_typed_referent`: it skips `ParentTarget`-carrier /// clauses (which continue the same chosen referent) and stops at the first /// conditional clause or non-carrier referent. -fn chain_prior_referent_is_chosen_target(clauses: &[ClauseIr]) -> bool { +/// +/// Returns the `TargetOnly` target filter (not just a bool) so callers can read +/// its zone: a graveyard-scoped chosen target drives Conduit of Worlds' paid +/// during-resolution cast (CR 608.2g). The `parent_target_is_chosen` bool is the +/// `.is_some()` of this result. +fn chain_prior_chosen_target(clauses: &[ClauseIr]) -> Option<&TargetFilter> { for prev in clauses.iter().rev() { if prev.condition.is_some() { - return false; + return None; } - if matches!(prev.parsed.effect, Effect::TargetOnly { .. }) { - return true; + if let Effect::TargetOnly { target } = &prev.parsed.effect { + return Some(target); } if has_typed_target_widened(&prev.parsed.effect) { // A typed referent that is not a bare target selection (an exile/ // zone publisher, or pump/destroy/etc. of a target) is not an // Emry-style chosen graveyard pick — its "that card" anaphor keeps // the impulse/tracked-set grant. - return false; + return None; } if matches!( prev.parsed.effect.target_filter(), @@ -20047,9 +20094,9 @@ fn chain_prior_referent_is_chosen_target(clauses: &[ClauseIr]) -> bool { ) { continue; } - return false; + return None; } - false + None } /// CR 608.2c: Is the chain's MOST-RECENT object referent a just-created token @@ -23186,11 +23233,30 @@ fn try_parse_cast_effect(lower: &str, ctx: &ParseContext) -> Option { // when the permission is exercised, and the not-cast fallback relies on // the standing permission, neither of which the one-shot // during-resolution path models. + // CR 608.2g: a paid, single-target, no-duration "cast that card" bound + // to a CHOSEN target is also a during-resolution cast (Conduit of Worlds: + // "Choose target nonland permanent card in your graveyard. … you may cast + // that card."). The controller pays the real printed cost with normal + // mana (`mana_spend_permission: None`) as the ability resolves. This is + // the paid complement of the `without_paying` free case: the same + // structural gates (single target, no lingering duration, no alt-cost, no + // constraint) apply, plus the requirement that the anaphor's referent is + // a CHOSEN target (`Effect::TargetOnly`). + // + // CR 608.2g: the timing is a property of the resolving INSTRUCTION, not of + // the chosen card's zone. A no-duration "you may cast that card" is cast + // during resolution whether the chosen card is in a graveyard, hand, + // exile, or library; only an explicit standing duration (Emry's "you may + // cast that card THIS TURN" — `duration.is_some()`) keeps the lingering + // grant. The gate therefore reads `parent_target_is_chosen` (any zone), + // NOT the target's zone: an impulse/tracked-set anaphor + // (`ExiledBySource`/`TrackedSet`, `parent_target_is_chosen == false`) + // still keeps `LingeringPermission`. let driver = if mode == CardPlayMode::Cast - && without_paying && alt_ability_cost.is_none() && duration.is_none() && constraint.is_none() + && (without_paying || ctx.parent_target_is_chosen) { crate::types::ability::CastFromZoneDriver::DuringResolution } else { @@ -31539,8 +31605,11 @@ pub(crate) fn parse_effect_chain_ir( // restricted to chosen-target referents (Emry), excluding impulse // publishers (Territorial Bruntar's `ExileFromTopUntil`). An "if you // do" object anchor is a created/tracked referent, not a target - // selection, so it does not count here. - let parent_target_is_chosen = chain_prior_referent_is_chosen_target(builder.clauses()); + // selection, so it does not count here. The owned filter is carried + // forward solely to bind the anaphor; the bool is its `.is_some()`. + let chain_prior_chosen_target_filter = + chain_prior_chosen_target(builder.clauses()).cloned(); + let parent_target_is_chosen = chain_prior_chosen_target_filter.is_some(); // CR 608.2c (issue #1670): Consumption signal for the body "its // controller may" antecedent. True only when the rung ladder below // falls through to `chain_parent_target_controller_scope` for THIS @@ -31666,6 +31735,10 @@ pub(crate) fn parse_effect_chain_ir( source_becomes_attachment_in_chain: chain_source_becomes_attachment(builder.clauses()), effect_chain_full_lower: ctx.effect_chain_full_lower.clone(), parent_target_is_chosen, + // CR 608.2c + CR 601.2a: carry the chosen-target filter (with its + // zone) so `try_parse_cast_effect`'s anaphor branch can scope the + // "you may cast that card" driver to a graveyard-chosen target. + chain_prior_chosen_target: chain_prior_chosen_target_filter, // CR 608.2c + CR 400.7: seed the zone published by an earlier // "choose card(s) in " producer so this chunk's "put those // cards onto the battlefield" anaphor binds its `TrackedSet` move to @@ -32535,7 +32608,24 @@ pub(crate) fn parse_effect_chain_ir( // play permission, not a choice to apply the continuous effect now. Keep // the actor context derived from the printed "you may", but lower both // permission grants as mandatory so resolution installs the permission. + // CR 608.2g + CR 608.2c: the "may" in a paid during-resolution graveyard + // "you may cast that card" (Conduit of Worlds) belongs to the interactive + // `GraveyardPaidCast` accept/decline offer, NOT to a generic + // `OptionalEffectChoice` wrapper. A redundant wrapper would additionally + // latch the "if you do" rider on acceptance — BEFORE the cast commits — + // so declining the offer (or failing payment) would wrongly install the + // rider. Lowering it mandatory makes the offer the sole "may" and leaves + // the commit-point latch (casting_costs.rs) as the sole authority for the + // rider gate. Mirrors the `FreeCastFromZones` arm above. let is_optional = if matches!(&clause.effect, Effect::FreeCastFromZones { .. }) + || matches!( + &clause.effect, + Effect::CastFromZone { + driver: crate::types::ability::CastFromZoneDriver::DuringResolution, + without_paying_mana_cost: false, + .. + } + ) || clause_is_additional_land_permission(&clause) || clause_is_pay_to_end_effect_termination(normalized_text) { diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 4f6314c2dd..958432e9fd 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -31852,6 +31852,360 @@ fn render_silent_its_controller_cant_cast_spells() { assert_no_unimplemented(&def); } +/// CR 101.2 + CR 109.5: "You can't cast additional spells this turn" (Conduit of +/// Worlds' rider) — bare "you" scopes the ban to the source controller +/// (`SourceController`), and "additional spells" is an incremental BLANKET ban +/// (`spell_filter: None`), not a spell-type filter. Reverting the Step 4 parser +/// arms leaves "additional" mis-parsed as a type phrase, so +/// `try_parse_cant_cast_spells_effect` returns `None` and the clause is swallowed +/// into an `Effect::Unimplemented` — this assertion then fails. +#[test] +fn cant_cast_additional_spells_you_source_controller() { + let def = parse_effect_chain( + "You can't cast additional spells this turn.", + AbilityKind::Spell, + ); + assert!( + matches!( + &*def.effect, + Effect::AddRestriction { + restriction: GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SourceController, + expiry: RestrictionExpiry::EndOfTurn, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + .. + } + } + ), + "got {:?}", + def.effect + ); + // Reach guard: the "additional spells" qualifier was consumed as a blanket, + // not dropped into an unimplemented tail. + assert!(!matches!(&*def.effect, Effect::Unimplemented { .. })); +} + +/// CR 101.2: the "more spells" / "another spell" incremental qualifiers are the +/// same blanket ban (`spell_filter: None`) as "additional spells". +#[test] +fn cant_cast_more_and_another_spell_variants() { + for text in [ + "You can't cast more spells this turn.", + "You can't cast another spell this turn.", + ] { + let def = parse_effect_chain(text, AbilityKind::Spell); + assert!( + matches!( + &*def.effect, + Effect::AddRestriction { + restriction: GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SourceController, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + .. + } + } + ), + "{text} got {:?}", + def.effect + ); + } +} + +/// CR 101.2: the new last-ordered "you" subject arm must NOT shadow the longer +/// "your opponents" possessive tag (Silence), and the "additional/more/another" +/// blanket arms must NOT over-consume a genuine typed spell filter ("creature +/// spells"). Both negatives are paired with the positive above. +#[test] +fn cant_cast_you_arm_does_not_shadow_opponents_or_typed_filter() { + // "your opponents" still binds to OpponentsOfSourceController, not swallowed + // by a "you" prefix match. + let opponents = parse_effect_chain( + "Your opponents can't cast spells this turn.", + AbilityKind::Spell, + ); + assert!( + matches!( + &*opponents.effect, + Effect::AddRestriction { + restriction: GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::OpponentsOfSourceController, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + .. + } + } + ), + "opponents got {:?}", + opponents.effect + ); + + // A genuine typed filter ("creature spells") is still parsed as a filter, not + // consumed by the blanket qualifier arm. + let creatures = parse_effect_chain( + "You can't cast creature spells this turn.", + AbilityKind::Spell, + ); + assert!( + matches!( + &*creatures.effect, + Effect::AddRestriction { + restriction: GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SourceController, + activity: ProhibitedActivity::CastSpells { + spell_filter: Some(_), + }, + .. + } + } + ), + "creature spells got {:?}", + creatures.effect + ); +} + +/// Conduit of Worlds' full line-2 effect chain (verbatim, minus the `{T}` cost +/// and "Activate only as a sorcery" which are cost/timing metadata parsed +/// elsewhere). Each clause and the CR rule that governs it: +/// CR 601.2c: TargetOnly{nonland permanent card in graveyard} — choose target. +/// → CR 608.2g (+ CR 601.2a–i): CastFromZone{ParentTarget, DuringResolution, +/// paid, normal mana} — an effect that allows casting a spell during +/// resolution — gated on "if you haven't cast a spell this turn". +/// → CR 608.2c + CR 101.2: AddRestriction{CastSpells{None}, +/// SourceController, EndOfTurn} — a later "if you do" instruction whose +/// meaning depends on the earlier optional cast (608.2c: apply the +/// instructions in written order), imposing a "can't" that takes +/// precedence (101.2). +/// Reverting Step 5 leaves the cast at `LingeringPermission`; reverting Step 4 +/// leaves the rider unimplemented. Both surface here. +#[test] +fn conduit_of_worlds_line2_paid_graveyard_during_resolution() { + let def = parse_effect_chain( + "Choose target nonland permanent card in your graveyard. If you haven't cast a spell this turn, you may cast that card. If you do, you can't cast additional spells this turn.", + AbilityKind::Activated, + ); + + // Root: the chosen graveyard target. + assert!( + matches!(&*def.effect, Effect::TargetOnly { .. }), + "root got {:?}", + def.effect + ); + let target_zone = def.effect.target_filter().and_then(|f| f.extract_in_zone()); + assert_eq!( + target_zone, + Some(crate::types::zones::Zone::Graveyard), + "chosen target must be graveyard-scoped, got {:?}", + def.effect.target_filter() + ); + + // Clause 2: the paid, during-resolution graveyard cast of "that card". + let cast = def + .sub_ability + .as_deref() + .expect("TargetOnly must chain the cast clause"); + assert!( + matches!( + &*cast.effect, + Effect::CastFromZone { + target: TargetFilter::ParentTarget, + without_paying_mana_cost: false, + mode: Cast, + driver: DuringResolution, + mana_spend_permission: None, + .. + } + ), + "cast clause got {:?}", + cast.effect + ); + // The "if you haven't cast a spell this turn" resolution gate is present (not + // swallowed). Assert the concrete check — spells cast this turn by the + // controller (untyped) equal to zero — mirroring the rider-gate precision + // below, so a regression that flips the comparator or retargets the quantity + // is caught rather than passing on the variant alone. + assert!( + matches!( + cast.condition.as_ref(), + Some(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::SpellsCastThisTurn { + scope: crate::types::ability::CountScope::Controller, + filter: None, + }, + }, + comparator: Comparator::EQ, + rhs: QuantityExpr::Fixed { value: 0 }, + }) + ), + "cast condition got {:?}", + cast.condition + ); + + // Clause 3: the "if you do" self cast-ban rider. + let rider = cast + .sub_ability + .as_deref() + .expect("cast clause must chain the rider"); + assert!( + matches!( + &*rider.effect, + Effect::AddRestriction { + restriction: GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SourceController, + expiry: RestrictionExpiry::EndOfTurn, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + .. + } + } + ), + "rider got {:?}", + rider.effect + ); + assert!( + matches!( + rider.condition.as_ref(), + Some(crate::types::ability::AbilityCondition::EffectOutcome { + signal: crate::types::ability::EffectOutcomeSignal::OptionalEffectPerformed, + }) + ), + "rider gate got {:?}", + rider.condition + ); + + // Reach guard: no Effect::Unimplemented anywhere in the chain. + let mut node = Some(&def); + while let Some(d) = node { + assert!( + !matches!(&*d.effect, Effect::Unimplemented { .. }), + "unexpected Unimplemented: {:?}", + d.effect + ); + node = d.sub_ability.as_deref(); + } +} + +#[test] +fn paid_chosen_target_cast_is_during_resolution_for_each_supported_zone() { + for zone in ["hand", "exile", "library"] { + let def = parse_effect_chain( + &format!( + "Choose target nonland permanent card in your {zone}. You may cast that card." + ), + AbilityKind::Activated, + ); + let cast = def + .sub_ability + .as_deref() + .expect("chosen target must chain to its cast instruction"); + assert!( + matches!( + &*cast.effect, + Effect::CastFromZone { + target: TargetFilter::ParentTarget, + without_paying_mana_cost: false, + mode: Cast, + driver: DuringResolution, + duration: None, + .. + } + ), + "a no-duration chosen-target cast from {zone} must be during resolution; got {:?}", + cast.effect + ); + } +} + +/// CR 305.1 + CR 602.5d + CR 608.2g: the WHOLE Conduit of Worlds card (verbatim +/// Oracle text) parses to an honest, fully-supported AST — line 1 to a +/// `GraveyardCastPermission` (play lands), line 2 to a sorcery-speed activated +/// ability whose chain has NO `Effect::Unimplemented` anywhere. This is the +/// coverage-honesty gate: any swallowed clause (the paid cast, the condition, the +/// "if you do" rider, or the sorcery-speed restriction) surfaces here. +#[test] +fn conduit_of_worlds_full_card_is_supported_no_unimplemented() { + let parsed = parse_oracle_text( + "You may play lands from your graveyard.\n{T}: Choose target nonland permanent card in your graveyard. If you haven't cast a spell this turn, you may cast that card. If you do, you can't cast additional spells this turn. Activate only as a sorcery.", + "Conduit of Worlds", + &[], + &["Artifact".to_string()], + &[], + ); + + // Line 1: the graveyard land-play permission static. + assert!( + parsed.statics.iter().any(|s| matches!( + s.mode, + crate::types::statics::StaticMode::GraveyardCastPermission { + play_mode: Play, + .. + } + )), + "line 1 must parse to a Play GraveyardCastPermission, got {:?}", + parsed.statics + ); + + // Line 2: the sorcery-speed activated ability. + let activated = parsed + .abilities + .iter() + .find(|a| a.kind == AbilityKind::Activated) + .expect("line 2 must parse to an activated ability"); + assert!( + activated + .activation_restrictions + .contains(&crate::types::ability::ActivationRestriction::AsSorcery), + "line 2 must carry the AsSorcery restriction (CR 602.5d)" + ); + + // No Effect::Unimplemented anywhere in any parsed ability chain. + fn chain_has_unimplemented(def: &AbilityDefinition) -> bool { + matches!(&*def.effect, Effect::Unimplemented { .. }) + || def + .sub_ability + .as_deref() + .is_some_and(chain_has_unimplemented) + || def + .else_ability + .as_deref() + .is_some_and(chain_has_unimplemented) + } + for a in &parsed.abilities { + assert!( + !chain_has_unimplemented(a), + "no ability chain may contain Effect::Unimplemented, got {:?}", + a.effect + ); + } +} + +/// CR 608.2c + CR 611.2a: Emry, Lurker in the Loch — "Choose target artifact card +/// in your graveyard. You may cast that card this turn." — is a graveyard-scoped +/// chosen target too, but its "this turn" duration keeps it a +/// `LingeringPermission` grant (a standing until-end-of-turn permission), NOT a +/// during-resolution cast. This is the negative that proves the Step 5 upgrade is +/// gated on `duration.is_none()`, and that Emry is not regressed. +#[test] +fn emry_this_turn_grant_stays_lingering_not_during_resolution() { + let def = parse_effect_chain( + "Choose target artifact card in your graveyard. You may cast that card this turn.", + AbilityKind::Activated, + ); + let cast = def + .sub_ability + .as_deref() + .expect("TargetOnly must chain the cast clause"); + assert!( + matches!( + &*cast.effect, + Effect::CastFromZone { + driver: LingeringPermission, + .. + } + ), + "Emry cast clause got {:?}", + cast.effect + ); +} + /// CR 305.1 + CR 101.2 + CR 201.2: Conjurer's Ban's compound restriction — /// "Until your next turn, spells with the chosen name can't be cast and lands /// with the chosen name can't be played." The PASSIVE-voice, card-scoped diff --git a/crates/engine/src/parser/oracle_ir/context.rs b/crates/engine/src/parser/oracle_ir/context.rs index 3e6b91cd13..3de649811a 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -266,6 +266,15 @@ pub(crate) struct ParseContext { /// `ExileFromTopUntil` referent (Territorial Bruntar) that /// `parent_target_available` would otherwise include. pub parent_target_is_chosen: bool, + /// CR 608.2c + CR 601.2a: the chain's prior chosen-target FILTER — the + /// `Effect::TargetOnly { target }` filter that `parent_target_is_chosen` + /// reports the presence of (Emry's / Conduit of Worlds' "Choose target … + /// card in your graveyard"). It binds a downstream "you may cast that card" + /// anaphor to the chosen object; timing comes independently from the + /// instruction plus its duration, never from this target's zone. Seeded + /// alongside `parent_target_is_chosen` in the chunk loop; `None` on every + /// standalone and non-chosen parse. + pub chain_prior_chosen_target: Option, /// CR 608.2c + CR 400.7: Source zone of the tracked set that a downstream /// "put those cards / put them onto the battlefield" anaphor (a /// `TargetFilter::TrackedSet`) must scan. Set by a producer clause that diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 49398a4563..d73ef15795 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -13764,9 +13764,14 @@ fn graveyard_play_and_cast_permission_wrenn_emblem() { } #[test] -fn graveyard_cast_permission_conduit_of_worlds() { +fn graveyard_cast_permission_permanent_spells_class() { + // Card-neutral fabricated-text regression for the "cast permanent spells from + // your graveyard" permission class (play_mode: Cast, Permanent-scoped). NOT + // Conduit of Worlds — Conduit's real line 1 is "You may play lands from your + // graveyard." (play_mode: Play, land-scoped), covered by + // `graveyard_play_permission_crucible`. let text = "You may cast permanent spells from your graveyard."; - let def = parse_static_line(text).expect("should parse Conduit text"); + let def = parse_static_line(text).expect("should parse permanent-spell permission"); assert!(matches!( def.mode, StaticMode::GraveyardCastPermission { diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 05d3dc44c4..567446cbac 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -3128,6 +3128,20 @@ pub enum RestrictionPlayerScope { /// as `TargetedPlayer`/`DefendingPlayer`. Mirrors the existing /// `ControllerRef::ScopedPlayer` / `TargetFilter::ScopedPlayer` siblings. ScopedPlayer, + /// CR 109.5 + CR 611.2a: The affected "you" — the "you" in "you can't cast + /// additional spells this turn" (Conduit of Worlds). Because that rider is + /// created by the resolution of an activated ability, "you" is the player who + /// activated it (CR 109.5), and the resulting rules-modifying continuous + /// effect lasts until end of turn (CR 611.2a) — a source-independent + /// turn-based duration, so it outlives the source. + /// `add_restriction::fill_runtime_fields` + /// therefore lowers this scope to `SpecificPlayer(original_controller)` at + /// creation — locking the activator so the ban survives the source changing + /// controller or leaving play — exactly like the other affected-player scopes + /// (`TargetedPlayer`, `DefendingPlayer`, `ScopedPlayer`, + /// `ParentObjectTargetController`). This parser-facing scope is never stored: + /// enforcement and display only ever see the lowered `SpecificPlayer`. + SourceController, } // --------------------------------------------------------------------------- @@ -25231,6 +25245,54 @@ impl ResolvedAbility { } } + /// Marks only the first source-ordered `if you do` gate in a suspended + /// continuation. A paid resolution-time cast completes one + /// optional instruction; later, independent optional instructions must not + /// inherit that result. + pub fn set_first_optional_effect_performed_gate(&mut self, performed: bool) -> bool { + if self + .condition + .as_ref() + .is_some_and(AbilityCondition::is_optional_effect_performed) + { + self.context.optional_effect_performed = performed; + return true; + } + if self + .sub_ability + .as_mut() + .is_some_and(|sub| sub.set_first_optional_effect_performed_gate(performed)) + { + return true; + } + self.else_ability.as_mut().is_some_and(|else_branch| { + else_branch.set_first_optional_effect_performed_gate(performed) + }) + } + + /// CR 608.2c: Does any node in this local ability chain carry an + /// `EffectOutcome { OptionalEffectPerformed }` ("if you do") gate? Mirrors the + /// traversal of [`Self::set_optional_effect_performed_recursive`] (self → + /// sub-ability → else-branch). Used at the paid during-resolution cast-commit + /// point to gate the retroactive latch: only a stashed continuation carrying + /// such a rider (Conduit of Worlds' "If you do, you can't cast additional + /// spells this turn") is stamped, so the shared cast-finalize path does not + /// misfire on a during-resolution cast without an "if you do" rider (Cascade, + /// Discover) or on a normal hand cast. + pub fn has_optional_effect_performed_gate(&self) -> bool { + self.condition + .as_ref() + .is_some_and(AbilityCondition::is_optional_effect_performed) + || self + .sub_ability + .as_ref() + .is_some_and(|sub| sub.has_optional_effect_performed_gate()) + || self + .else_ability + .as_ref() + .is_some_and(|else_branch| else_branch.has_optional_effect_performed_gate()) + } + /// CR 608.2d: Stamp `context.guess_outcome` across the local ability chain. /// Used when the `Effect::OpponentGuess` answer arrives after the prompt /// suspended the parent chain — the stashed branch continuation was captured @@ -25440,6 +25502,31 @@ mod tests { use crate::types::mana::ZoneSpendPolarity; use crate::types::zones::Zone; + #[test] + fn first_optional_effect_gate_does_not_latch_a_later_independent_gate() { + let mut first = ResolvedAbility::new(Effect::NoOp, vec![], ObjectId(1), PlayerId(0)); + first.condition = Some(AbilityCondition::EffectOutcome { + signal: EffectOutcomeSignal::OptionalEffectPerformed, + }); + + let mut second = ResolvedAbility::new(Effect::NoOp, vec![], ObjectId(1), PlayerId(0)); + second.condition = Some(AbilityCondition::EffectOutcome { + signal: EffectOutcomeSignal::OptionalEffectPerformed, + }); + first.sub_ability = Some(Box::new(second)); + + assert!(first.set_first_optional_effect_performed_gate(true)); + assert!(first.context.optional_effect_performed); + assert!( + !first + .sub_ability + .as_ref() + .expect("second independent gate exists") + .context + .optional_effect_performed + ); + } + /// CR 601.3: `without_exile_anaphor` is the residual of a cast /// filter once the exile-set anaphor is discharged. The three shapes that /// matter to the chain-forwarded grant path: diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 555b3a5074..e34d5dd02e 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9538,6 +9538,40 @@ impl GameState { } } + /// CR 109.5 + CR 611.2a: drop any restored restriction whose affected player + /// is still the raw `RestrictionPlayerScope::SourceController` placeholder + /// (Conduit of Worlds' "you can't cast additional spells this turn"). + /// `add_restriction` ALWAYS lowers this scope to + /// `SpecificPlayer(original_controller)` at the instant the restriction is + /// created, so a live — and therefore any legitimately-captured — state never + /// carries the raw scope; for those states this is a no-op. Only an + /// untrusted, corrupt, or forged snapshot can serialize the raw placeholder, + /// and both runtime consumers (`casting.rs`, `combat.rs`) fail OPEN on it + /// (returning `false`, silently bypassing the prohibition in release, and the + /// casting consumer's `debug_assert!(false)` would panic in debug/test builds + /// after such a restore). The original activator cannot be recovered from the + /// restored state — per CR 109.5 the "you" of an activated ability is the + /// player who activated it, and per CR 611.2a the restriction lasts until end + /// of turn regardless of the source, so the source's CURRENT controller is + /// not necessarily the original activator and must not be inferred — so the + /// only safe repair is to discard the unbindable restriction, which yields + /// the same inert outcome the consumers already produce, minus the panic + /// hazard. Called from `PersistedGameState::into_game_state`, the single + /// production restore chokepoint, so both the Raw and Trusted paths are + /// hardened. + pub fn drop_unresolved_source_controller_restrictions(&mut self) { + use crate::types::ability::{GameRestriction, RestrictionPlayerScope}; + self.restrictions.retain(|restriction| { + !matches!( + restriction, + GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SourceController, + .. + } + ) + }); + } + /// CR 732.2a: the seat whose driving period `last_loop_action_sequence` currently records. /// /// CR 732.2a lets "the player with priority … suggest a shortcut by describing a sequence of @@ -9659,6 +9693,11 @@ impl PersistedGameState { // CR 732.2a (FIX-3): drop stale transient loop-detection bookkeeping on load unless the save // sits in an object-growth shortcut window whose pending resolution still consumes it. state.migrate_transient_loop_sequence(); + // CR 109.5 + CR 611.2a: discard any restriction still carrying the raw + // `SourceController` placeholder — a legitimately-captured state never has + // one (it is lowered to the activator at creation), so this only sanitizes + // corrupt/forged snapshots and closes the fail-open path in both consumers. + state.drop_unresolved_source_controller_restrictions(); state } } @@ -23648,6 +23687,73 @@ mod tests { } } + /// CR 109.5 + CR 611.2a: a restored restriction still carrying the raw + /// `SourceController` placeholder (which `add_restriction` always lowers to + /// `SpecificPlayer` at creation, so it can only appear in a corrupt or forged + /// snapshot) is dropped on restore through BOTH the Raw and Trusted paths, + /// closing the fail-open hole in the casting/combat consumers. A sibling + /// `SpecificPlayer` ban — the lowered form a legitimate capture carries — + /// survives untouched, proving the sanitizer targets only the unbindable + /// placeholder. + #[test] + fn raw_source_controller_restriction_is_dropped_on_restore_in_both_envelopes() { + use crate::types::ability::{ + GameRestriction, ProhibitedActivity, RestrictionExpiry, RestrictionPlayerScope, + }; + use crate::types::identifiers::ObjectId; + use crate::types::player::PlayerId; + + let mut state = GameState::new_two_player(42); + // Corrupt/forged: the unbindable raw scope that must never survive restore. + state.restrictions.push(GameRestriction::ProhibitActivity { + source: ObjectId(5), + affected_players: RestrictionPlayerScope::SourceController, + expiry: RestrictionExpiry::EndOfTurn, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + }); + // The lowered ban a legitimate capture carries — must be preserved. + state.restrictions.push(GameRestriction::ProhibitActivity { + source: ObjectId(6), + affected_players: RestrictionPlayerScope::SpecificPlayer(PlayerId(0)), + expiry: RestrictionExpiry::EndOfTurn, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + }); + + let raw = serde_json::to_value(PersistedGameState::Raw(Box::new(state.clone()))) + .expect("serialize raw fixture"); + let trusted = serde_json::to_value(PersistedGameState::capture(state)) + .expect("serialize trusted fixture"); + + for persisted in [raw, trusted] { + let restored = serde_json::from_value::(persisted) + .expect("restriction fixture restores") + .into_game_state(); + + assert!( + !restored.restrictions.iter().any(|restriction| matches!( + restriction, + GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SourceController, + .. + } + )), + "raw SourceController restriction must be dropped on restore, got {:?}", + restored.restrictions + ); + assert!( + restored.restrictions.iter().any(|restriction| matches!( + restriction, + GameRestriction::ProhibitActivity { + affected_players: RestrictionPlayerScope::SpecificPlayer(PlayerId(0)), + .. + } + )), + "the lowered SpecificPlayer ban must survive restore, got {:?}", + restored.restrictions + ); + } + } + #[test] fn v1_unlabeled_trigger_carriers_migrate_to_legacy_delayed() { let mut state = trigger_continuation_fixture();