From 247405d0aca92bb572c36f5dff2ac03f51da7f3a Mon Sep 17 00:00:00 2001 From: Jacob Woodson Date: Mon, 3 Aug 2026 06:37:08 -0500 Subject: [PATCH 1/9] Add Conduit of Worlds --- crates/engine/data/known-tokens.toml | 14 + crates/engine/src/game/casting.rs | 14 + crates/engine/src/game/casting_costs.rs | 22 ++ crates/engine/src/game/casting_tests.rs | 260 +++++++++++++++ crates/engine/src/game/combat.rs | 9 +- crates/engine/src/game/derived_views.rs | 6 +- .../src/game/effects/add_restriction.rs | 20 ++ .../engine/src/game/effects/cast_from_zone.rs | 25 +- crates/engine/src/game/effects/mod.rs | 13 + crates/engine/src/parser/oracle_effect/mod.rs | 137 +++++++- .../engine/src/parser/oracle_effect/tests.rs | 307 ++++++++++++++++++ crates/engine/src/parser/oracle_ir/context.rs | 10 + .../engine/src/parser/oracle_static/tests.rs | 9 +- crates/engine/src/types/ability.rs | 36 ++ scripts/fetch-token-sets.sh | 26 +- 15 files changed, 867 insertions(+), 41 deletions(-) diff --git a/crates/engine/data/known-tokens.toml b/crates/engine/data/known-tokens.toml index 3d9730774d..43bdd0fa81 100644 --- a/crates/engine/data/known-tokens.toml +++ b/crates/engine/data/known-tokens.toml @@ -17003,9 +17003,11 @@ 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", @@ -17063,6 +17065,8 @@ 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", @@ -17095,6 +17099,7 @@ source_card_names = [ "Gilded Pinions", "Gimli of the Glittering Caves", "Gleaming Barrier", + "Gleaming Splendor", "Glittermonger", "Gluntch, the Bestower", "Glóin, Dwarf Emissary", @@ -17160,6 +17165,7 @@ source_card_names = [ "Life Insurance", "Lobelia Sackville-Baggins", "Locke, Treasure Hunter", + "Long-Bodied Grey Dog", "Loot Dispute", "Lost Mine of Phandelver", "Lotho, Corrupt Shirriff", @@ -17202,6 +17208,7 @@ source_card_names = [ "Old Gnawbone", "Old Rutstein", "Olivia, Opulent Outlaw", + "Orcrist, Goblin-cleaver", "Orochi Soul-Reaver", "Pain Distributor", "Patient Naturalist", @@ -17269,6 +17276,9 @@ source_card_names = [ "Skullport Merchant", "Smashing Success", "Smaug", + "Smaug the Impenetrable", + "Smaug the Magnificent", + "Smaug, Wicked Worm", "Smoke Blessing", "Smoke Spirits' Aid", "Smothering Tithe", @@ -17302,11 +17312,15 @@ 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 7aa242ac0d..fae70d765d 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -832,6 +832,20 @@ fn restriction_scope_matches_player( RestrictionPlayerScope::OpponentsOfSourceController => { source_controller.is_some_and(|controller| controller != caster) } + // CR 109.5 + CR 611.2c: the affected "you" ("you can't cast additional + // spells this turn" — Conduit of Worlds) is the player who activated the + // ability, fixed at resolution. `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. + 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 2bf48a4d97..6951067305 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -9611,6 +9611,28 @@ 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, so propagate the signal into the stashed continuation. Gated on the + // gate's presence so the shared finalize path does not misfire: a normal hand + // cast is announced only at a priority window (no `AbilityContinuation` frame + // is stack-top there), and a during-resolution cast with no "if you do" rider + // (Cascade, Discover) carries no such gate, so neither is latched. + if let Some(frame) = state.active_ability_continuation_frame_mut() { + if frame.pending.chain.has_optional_effect_performed_gate() { + frame + .pending + .chain + .set_optional_effect_performed_recursive(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 0213bdd131..a9df359389 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -27549,6 +27549,72 @@ 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 + CR 611.2c: 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 exists independently of its source + // (CR 611.2c) and lasts until end of turn (CR 611.2a): 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 611.2c: the effect is locked to the activator. 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 + CR 611.2c: the effect also survives the source leaving play — + // 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}; @@ -49456,6 +49522,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 + CR 611.2c: 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 3c263b363e..6af0557402 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -3772,11 +3772,18 @@ 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. 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 97e4c720b2..5568e0a7ac 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -1230,10 +1230,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..46b1b6304f 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 + CR 611.2c: `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 + // exists independently of its source (CR 611.2c) and lasts until + // end of turn (CR 611.2a): 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 9c2f7c4517..9de61dcef1 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -565,15 +565,24 @@ 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). + // CR 608.2g + CR 609.4b: paid during-resolution graveyard cast. 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). The gate no longer requires + // `mana_spend_permission.is_some()`: the only pre-existing `DuringResolution` + // graveyard producer that reaches here with `mana_spend_permission: None` is + // the new Conduit-class anaphor (`parent_target_is_graveyard_scoped`) — every + // legacy any-mana producer sets `Some(AnyTypeOrColor)`, and all free casts + // take `without_paying`, so this relaxation changes behavior for exactly the + // normal-mana normal-cost class and nothing else. let graveyard_paid_cast = !without_paying - && mana_spend_permission.is_some() && driver.is_during_resolution() && alt_ability_cost.is_none() && duration.is_none() diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 7cf88119ab..ce118d6d6b 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2957,6 +2957,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/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index bd316881c1..e1cd786195 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -3952,6 +3952,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 PutCounter "up to N" multi_target. // The multi_target is lost in the AST→Effect lowering chain, so we re-extract it // from the original text when the effect is PutCounter with a targeted filter. @@ -19782,32 +19823,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(), @@ -19815,9 +19862,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 @@ -22581,6 +22628,22 @@ fn parse_owned_plus_lesser_exiled_subject(i: &str) -> OracleResult<'_, ()> { Ok((i, ())) } +/// CR 608.2g + CR 601.2a: Is the "that card" anaphor's referent a graveyard-scoped +/// CHOSEN target (`Effect::TargetOnly` with `InZone { Graveyard }` — Conduit of +/// Worlds)? Only such a referent upgrades a *paid* "you may cast that card" to a +/// during-resolution cast; exile/hand/library chosen targets and impulse/tracked +/// anaphors (`parent_target_is_chosen == false`) keep the lingering permission. +/// Reads the zone off the chain's prior chosen target via the shared +/// `TargetFilter::extract_in_zone` building block. +fn parent_target_is_graveyard_scoped(ctx: &ParseContext) -> bool { + ctx.parent_target_is_chosen + && ctx + .chain_prior_chosen_target + .as_ref() + .and_then(TargetFilter::extract_in_zone) + == Some(crate::types::zones::Zone::Graveyard) +} + /// 1. Anaphoric — "cast it", "cast that spell", "cast those cards" — target is /// `ParentTarget` (refers to the cards exiled / chosen by a prior effect). /// 2. Constrained — "cast a [type-phrase] [from ] [with mana value ] @@ -22695,11 +22758,24 @@ 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 graveyard "cast that + // card" bound to a CHOSEN graveyard 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 graveyard-scoped chosen target — Emry's + // "you may cast that card THIS TURN" keeps its lingering grant because + // its `duration` is `UntilEndOfTurn`, and exile/hand/library chosen + // anaphors keep `LingeringPermission` because their zone is not the + // graveyard. let driver = if mode == CardPlayMode::Cast - && without_paying && alt_ability_cost.is_none() && duration.is_none() && constraint.is_none() + && (without_paying || parent_target_is_graveyard_scoped(ctx)) { crate::types::ability::CastFromZoneDriver::DuringResolution } else { @@ -30883,8 +30959,12 @@ 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 so the anaphor branch can read the chosen target's zone + // (Conduit of Worlds' paid graveyard cast); 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 @@ -31004,6 +31084,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 @@ -31873,7 +31957,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 086e14d473..e8c3220a76 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -31505,6 +31505,313 @@ 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 + ); +} + +/// CR 608.2c + CR 608.2g + CR 601.2a + CR 101.2: 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). The chain is: +/// TargetOnly{nonland permanent card in graveyard} +/// → CastFromZone{ParentTarget, DuringResolution, paid, normal mana} +/// gated on "if you haven't cast a spell this turn" +/// → AddRestriction{CastSpells{None}, SourceController, EndOfTurn} +/// gated on "if you do". +/// 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) — a QuantityCheck on spells cast this turn. + assert!( + matches!( + cast.condition.as_ref(), + Some(crate::types::ability::AbilityCondition::QuantityCheck { .. }) + ), + "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(); + } +} + +/// 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 dab5fa4ea1..a4e084f42b 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -256,6 +256,16 @@ 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"). Carries the target's zone so a downstream "you + /// may cast that card" anaphor can scope its cast driver: a graveyard-scoped + /// chosen target with no lingering duration is a during-resolution paid cast + /// (CR 608.2g), whereas an exile/hand/library chosen target keeps the + /// lingering permission. 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 26e43febd4..e723383a94 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -13527,9 +13527,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 4fa9aa4b43..2292e1c951 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -3070,6 +3070,19 @@ pub enum RestrictionPlayerScope { /// as `TargetedPlayer`/`DefendingPlayer`. Mirrors the existing /// `ControllerRef::ScopedPlayer` / `TargetFilter::ScopedPlayer` siblings. ScopedPlayer, + /// CR 109.5 + CR 611.2a + CR 611.2c: 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 exists independently of its source (CR 611.2c), lasting + /// until end of turn (CR 611.2a). `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, } // --------------------------------------------------------------------------- @@ -23873,6 +23886,29 @@ impl ResolvedAbility { } } + /// 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 diff --git a/scripts/fetch-token-sets.sh b/scripts/fetch-token-sets.sh index 7586b94909..0deca1cc61 100755 --- a/scripts/fetch-token-sets.sh +++ b/scripts/fetch-token-sets.sh @@ -20,17 +20,21 @@ if ! command -v jq >/dev/null 2>&1; then exit 1 fi -mapfile -t CODES < <( - # tokenSetCode can name a legacy token pseudo-set that MTGJSON no longer - # publishes as its own file; the parent set file already carries data.tokens. - # tr strips the \r that Windows jq appends to every line — a code with a - # trailing \r malforms the download URL (curl exit 3: "URL rejected") for - # every set, and the resulting .missing markers then mask the retries. - jq -r '(reduce .data[].code as $code ({}; .[$code] = true)) as $known_codes - | .data[] - | select(.tokenSetCode != null and .tokenSetCode != "") - | .code, (.tokenSetCode | select($known_codes[.]))' "$SET_LIST" | tr -d '\r' | sort -u -) +# tokenSetCode can name a legacy token pseudo-set that MTGJSON no longer +# publishes as its own file; the parent set file already carries data.tokens. +# tr strips the \r that Windows jq appends to every line — a code with a +# trailing \r malforms the download URL (curl exit 3: "URL rejected") for +# every set, and the resulting .missing markers then mask the retries. +# The set codes are staged in a temp file and read with a plain `<` redirect +# rather than `mapfile < <(...)`: older Windows git-bash (4.4, no /dev/fd) +# aborts process substitution with "/dev/fd/NN: No such file or directory". +_codes_tmp="$(mktemp)" +jq -r '(reduce .data[].code as $code ({}; .[$code] = true)) as $known_codes + | .data[] + | select(.tokenSetCode != null and .tokenSetCode != "") + | .code, (.tokenSetCode | select($known_codes[.]))' "$SET_LIST" | tr -d '\r' | sort -u > "$_codes_tmp" +mapfile -t CODES < "$_codes_tmp" +rm -f "$_codes_tmp" if [ "${#CODES[@]}" -eq 0 ]; then echo "No token-bearing set codes found in SetList.json." From 9f301ae41f19b0531b076bf5828a910531c58995 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 3 Aug 2026 05:10:22 -0700 Subject: [PATCH 2/9] fix(PR-6958): strip generated token registry drift --- crates/engine/data/known-tokens.toml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/engine/data/known-tokens.toml b/crates/engine/data/known-tokens.toml index 43bdd0fa81..6d67943d15 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", @@ -448,8 +446,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", @@ -482,7 +478,6 @@ source_card_names = [ "Gilded Pinions", "Gimli of the Glittering Caves", "Gleaming Barrier", - "Gleaming Splendor", "Glittermonger", "Gluntch, the Bestower", "Glóin, Dwarf Emissary", @@ -548,7 +543,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", @@ -591,7 +585,6 @@ source_card_names = [ "Old Gnawbone", "Old Rutstein", "Olivia, Opulent Outlaw", - "Orcrist, Goblin-cleaver", "Orochi Soul-Reaver", "Pain Distributor", "Patient Naturalist", @@ -659,9 +652,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", @@ -695,15 +685,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", From a43d42d5e9acdbc506d02ac2ff7e2198fdf65335 Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:27:01 -0500 Subject: [PATCH 3/9] fix(PR-6958): harden SourceController restore + tighten Conduit tests Address CodeRabbit review on #6958: - Reject the fail-open path for a restored RestrictionPlayerScope::SourceController placeholder. add_restriction always lowers it to SpecificPlayer at creation, so a live/legitimately-captured state never carries the raw scope; only a corrupt or forged snapshot can. Both casting.rs and combat.rs consumers fail OPEN on it (returning false, bypassing the prohibition; the casting debug_assert would also panic in debug/test after such a restore). Add GameState::drop_unresolved_source_controller_restrictions and call it from the single restore chokepoint PersistedGameState::into_game_state so both the Raw and Trusted paths are scrubbed. The original activator cannot be recovered (CR 611.2c: the effect outlives its source), so the unbindable restriction is dropped rather than rebound to the source's current controller. Add a round-trip regression test across both envelopes and cross-reference the sanitizer from both consumers. - Attribute CR 608.2g (cast during resolution) and CR 608.2c (instruction order / "if you do" rider) per-clause in the Conduit line-2 test doc. - Tighten the cast resolution-gate assertion to the concrete QuantityCheck (spells cast this turn by the controller == 0) instead of matching the variant alone. Co-Authored-By: Claude Opus 4.8 --- crates/engine/src/game/casting.rs | 5 +- crates/engine/src/game/combat.rs | 4 +- .../engine/src/parser/oracle_effect/tests.rs | 36 ++++-- crates/engine/src/types/game_state.rs | 104 ++++++++++++++++++ 4 files changed, 137 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index fae70d765d..40f37ae580 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -838,7 +838,10 @@ fn restriction_scope_matches_player( // `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. + // 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, diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index 6af0557402..4e370ac57a 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -3777,7 +3777,9 @@ fn attack_passes_temporary_prohibition( // 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. + // 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 diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index e8c3220a76..e6767509ea 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -31615,14 +31615,18 @@ fn cant_cast_you_arm_does_not_shadow_opponents_or_typed_filter() { ); } -/// CR 608.2c + CR 608.2g + CR 601.2a + CR 101.2: 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). The chain is: -/// TargetOnly{nonland permanent card in graveyard} -/// → CastFromZone{ParentTarget, DuringResolution, paid, normal mana} -/// gated on "if you haven't cast a spell this turn" -/// → AddRestriction{CastSpells{None}, SourceController, EndOfTurn} -/// gated on "if you do". +/// 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] @@ -31667,11 +31671,23 @@ fn conduit_of_worlds_line2_paid_graveyard_during_resolution() { cast.effect ); // The "if you haven't cast a spell this turn" resolution gate is present (not - // swallowed) — a QuantityCheck on spells cast this turn. + // 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(crate::types::ability::AbilityCondition::QuantityCheck { .. }) + 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 diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 41cf2a04ff..1514ba8fb6 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -8779,6 +8779,38 @@ impl GameState { self.last_loop_action_sequence.clear(); } } + + /// CR 109.5 + CR 611.2a + CR 611.2c: 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 611.2c the effect outlives its source, so the + /// source's CURRENT controller is not necessarily the original "you" 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, + .. + } + ) + }); + } } /// Decodes both current trusted snapshots and historical raw `GameState` @@ -8872,6 +8904,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.2c: discard any restriction still carrying the raw + // `SourceController` placeholder — a legitimately-captured state never has + // one (it is lowered 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 } } @@ -21715,6 +21752,73 @@ mod tests { } } + /// CR 109.5 + CR 611.2c: 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(); From a36ead4abed2bb8bd65aab99642a1c913db01d87 Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:40:20 -0500 Subject: [PATCH 4/9] docs(PR-6958): correct CR citation for SourceController activator binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review: CR 611.2c governs which objects a characteristic/ control-changing continuous effect locks onto — it does NOT establish that a "you can't cast additional spells" restriction stays bound to the original activator. The activator binding is CR 109.5 (an activated ability's "you" is the player who activated it) and the persistence is CR 611.2a (the effect lasts until end of turn regardless of the source). Replace the misattributed CR 611.2c with CR 109.5 + CR 611.2a in the sanitizer doc, its call site, and the restore regression test doc. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- crates/engine/src/types/game_state.rs | 32 ++++++++++++++------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 1514ba8fb6..c720afc8d7 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -8780,10 +8780,10 @@ impl GameState { } } - /// CR 109.5 + CR 611.2a + CR 611.2c: 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 + /// 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 @@ -8792,13 +8792,15 @@ impl GameState { /// (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 611.2c the effect outlives its source, so the - /// source's CURRENT controller is not necessarily the original "you" 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. + /// 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| { @@ -8904,10 +8906,10 @@ 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.2c: discard any restriction still carrying the raw + // 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 at creation), so this only sanitizes corrupt/forged - // snapshots and closes the fail-open path in both consumers. + // 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 } @@ -21752,7 +21754,7 @@ mod tests { } } - /// CR 109.5 + CR 611.2c: a restored restriction still carrying the raw + /// 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, From da5a40e806e4bd848cca29ff234be8fec40267d0 Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:48:47 -0500 Subject: [PATCH 5/9] fix(PR-6958): re-baseline CR 603.5 prompt-census pin after main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt-census tripwire (engine.rs) was the only failing CI job on the merge ref. This PR's single effects/mod.rs hunk inserts +13 lines at :2957 (the paid graveyard-cast support), entirely above the three pinned `WaitingFor::OptionalEffectChoice` producers, shifting them 5996/6073/9048 -> 6009/6086/9061. The producers are byte-identical to origin/main at their old coordinates and add zero new census needles; scoped_library_search.rs:452 and engine.rs:11427 are unmoved; the total stays 37 and the partition stays 5/7/25. Pure coordinate drift — pin re-baselined with a drift-log adjudication entry, no producer gained or lost. Census test verified passing on the merged tree (the Linux path-separator form the pin uses matches CI's scan). Co-Authored-By: Claude Opus 4.8 --- crates/engine/src/game/engine.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 69a8d6576d..42c170e670 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15194,6 +15194,14 @@ mod stage2_injector_tests { // and the other two did NOT move, which located the insertion below them. // #6961 (2ead7aab1) + v0.44.0: `:5918/:5995/:8970 ⇒ :5996/:6073/:9048`, // uniform +78 above all three (whole-file delta +153/-15). + // #6958 (Conduit of Worlds): `:5996/:6073/:9048 ⇒ :6009/:6086/:9061`, + // uniform +13 above all three. This PR's ONLY effects/mod.rs hunk inserts + // 13 lines at `:2957` (`effect_manages_own_outcome_flag`, the paid + // graveyard-cast support), entirely above these producers, and adds zero + // new census needles. Producers re-read at their new coordinates are + // byte-identical to `origin/main` at the old ones, the other two entries + // (scoped_library_search:452, engine.rs:11427) are UNMOVED, and the total + // stays 37 with the partition 5/7/25 — same set, new coordinates. // // ⚠ THIS ROW FAILS IN CI BEFORE IT FAILS LOCALLY, and that is not a bug in the // row. CI checks out `refs/pull//merge` — this branch merged with CURRENT @@ -15206,9 +15214,9 @@ mod stage2_injector_tests { // because that is what makes a NEW mint a counted event; a function + // content-hash anchor would end the drift class while keeping that property, // and is offered as a follow-up rather than taken unannounced mid-review. - "game/effects/mod.rs:5996".to_string(), - "game/effects/mod.rs:6073".to_string(), - "game/effects/mod.rs:9048".to_string(), + "game/effects/mod.rs:6009".to_string(), + "game/effects/mod.rs:6086".to_string(), + "game/effects/mod.rs:9061".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. From 45eaf9be652e64bb25cc2dad086c198ab37d0d56 Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:22:05 -0500 Subject: [PATCH 6/9] fix(PR-6958): correct remaining CR 611.2c citations; drop unrelated script change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address matthewevans review (head 476d1cd9): [MED] CR provenance: replace the PR-added CR 611.2c citations for the SourceController activator-binding / source-independence claim with the verified authorities — CR 109.5 (an activated ability's "you" is the player who activated it) and CR 611.2a (the rules-modifying continuous effect lasts until end of turn, a source-independent turn-based duration). CR 611.2c concerns the affected-object set for characteristic/control-changing effects, not this binding. Fixed in types/ability.rs, game/effects/add_restriction.rs, game/casting.rs, and the four sites in game/casting_tests.rs. Pre-existing correct 611.2c uses (affected-set locking) are untouched. [LOW] Remove the unrelated scripts/fetch-token-sets.sh Windows/git-bash process-substitution portability change; reverted to origin/main so it drops out of this PR (belongs in its own focused PR). Co-Authored-By: Claude Opus 4.8 --- crates/engine/src/game/casting.rs | 6 +++-- crates/engine/src/game/casting_tests.rs | 23 ++++++++-------- .../src/game/effects/add_restriction.rs | 20 +++++++------- crates/engine/src/types/ability.rs | 13 +++++----- scripts/fetch-token-sets.sh | 26 ++++++++----------- 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 74b90e583c..6e8905a9a9 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -832,9 +832,11 @@ fn restriction_scope_matches_player( RestrictionPlayerScope::OpponentsOfSourceController => { source_controller.is_some_and(|controller| controller != caster) } - // CR 109.5 + CR 611.2c: the affected "you" ("you can't cast additional + // 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, fixed at resolution. `add_restriction` lowers + // 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 diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index f394ba54c4..a6dceb59f3 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -27624,15 +27624,15 @@ 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 + CR 611.2c: a `CastSpells` prohibition scoped to + // 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 exists independently of its source - // (CR 611.2c) and lasts until end of turn (CR 611.2a): 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. + // 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( @@ -27674,14 +27674,15 @@ fn source_controller_scope_is_locked_to_activator_not_source() { assert!(is_blocked_by_cant_cast_spells(&state, PlayerId(0), None)); assert!(!is_blocked_by_cant_cast_spells(&state, PlayerId(1), None)); - // CR 611.2c: the effect is locked to the activator. Changing the source's - // controller does NOT move the ban to the new controller. + // 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 + CR 611.2c: the effect also survives the source leaving play — - // Conduit is a fragile 1/1 artifact creature that can be sacrificed / bounced + // 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)); @@ -49672,7 +49673,7 @@ fn activate_conduit_to_offer( } fn has_source_controller_cant_cast(state: &GameState) -> bool { - // CR 109.5 + CR 611.2c: Conduit's self-scoped "you can't cast additional + // 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` diff --git a/crates/engine/src/game/effects/add_restriction.rs b/crates/engine/src/game/effects/add_restriction.rs index 46b1b6304f..06a5c79d0e 100644 --- a/crates/engine/src/game/effects/add_restriction.rs +++ b/crates/engine/src/game/effects/add_restriction.rs @@ -161,16 +161,16 @@ fn fill_runtime_fields( *affected_players = RestrictionPlayerScope::SpecificPlayer(controller); } } - // CR 109.5 + CR 611.2a + CR 611.2c: `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 - // exists independently of its source (CR 611.2c) and lasts until - // end of turn (CR 611.2a): 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. + // 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); } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index dd8a5f6383..042724a1d8 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -3070,12 +3070,13 @@ pub enum RestrictionPlayerScope { /// as `TargetedPlayer`/`DefendingPlayer`. Mirrors the existing /// `ControllerRef::ScopedPlayer` / `TargetFilter::ScopedPlayer` siblings. ScopedPlayer, - /// CR 109.5 + CR 611.2a + CR 611.2c: 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 exists independently of its source (CR 611.2c), lasting - /// until end of turn (CR 611.2a). `add_restriction::fill_runtime_fields` + /// 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 diff --git a/scripts/fetch-token-sets.sh b/scripts/fetch-token-sets.sh index 0deca1cc61..7586b94909 100755 --- a/scripts/fetch-token-sets.sh +++ b/scripts/fetch-token-sets.sh @@ -20,21 +20,17 @@ if ! command -v jq >/dev/null 2>&1; then exit 1 fi -# tokenSetCode can name a legacy token pseudo-set that MTGJSON no longer -# publishes as its own file; the parent set file already carries data.tokens. -# tr strips the \r that Windows jq appends to every line — a code with a -# trailing \r malforms the download URL (curl exit 3: "URL rejected") for -# every set, and the resulting .missing markers then mask the retries. -# The set codes are staged in a temp file and read with a plain `<` redirect -# rather than `mapfile < <(...)`: older Windows git-bash (4.4, no /dev/fd) -# aborts process substitution with "/dev/fd/NN: No such file or directory". -_codes_tmp="$(mktemp)" -jq -r '(reduce .data[].code as $code ({}; .[$code] = true)) as $known_codes - | .data[] - | select(.tokenSetCode != null and .tokenSetCode != "") - | .code, (.tokenSetCode | select($known_codes[.]))' "$SET_LIST" | tr -d '\r' | sort -u > "$_codes_tmp" -mapfile -t CODES < "$_codes_tmp" -rm -f "$_codes_tmp" +mapfile -t CODES < <( + # tokenSetCode can name a legacy token pseudo-set that MTGJSON no longer + # publishes as its own file; the parent set file already carries data.tokens. + # tr strips the \r that Windows jq appends to every line — a code with a + # trailing \r malforms the download URL (curl exit 3: "URL rejected") for + # every set, and the resulting .missing markers then mask the retries. + jq -r '(reduce .data[].code as $code ({}; .[$code] = true)) as $known_codes + | .data[] + | select(.tokenSetCode != null and .tokenSetCode != "") + | .code, (.tokenSetCode | select($known_codes[.]))' "$SET_LIST" | tr -d '\r' | sort -u +) if [ "${#CODES[@]}" -eq 0 ]; then echo "No token-bearing set codes found in SetList.json." From 833afc9c6c024b96b4e83ea79930c92125b057fe Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:28:49 -0500 Subject: [PATCH 7/9] fix(PR-6958): derive paid cast timing from the instruction, not the target zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address matthewevans [HIGH]: paid, no-duration "you may cast that card" timing was inferred from the chosen target's zone (graveyard-only) instead of the resolving instruction. Per CR 608.2g, during-resolution casting is a property of the instruction, not the card's zone. - Parser (oracle_effect/mod.rs): the paid CastFromZoneDriver selection now gates on `ctx.parent_target_is_chosen` (an Effect::TargetOnly "that card" anaphor, any zone) instead of `parent_target_is_graveyard_scoped`. A no-duration paid chosen-target cast is DuringResolution regardless of the card's zone; `duration.is_none()` keeps Emry's "this turn" grant LingeringPermission, and impulse/tracked-set anaphors (parent_target_is_chosen == false) stay lingering. Removed the now-dead parent_target_is_graveyard_scoped fn. - Runtime (effects/cast_from_zone.rs): graveyard_paid_cast -> paid_during_ resolution_cast, gated on a castable non-battlefield origin (Graveyard | Exile | Library) rather than requiring Zone::Graveyard; initiate_cast_during_resolution casts the card from whichever zone it occupies. Hand is excluded — hand cards are not targetable, so a chosen-target cast never originates there (free hand casts use the separate selection path). CastFromZoneDriver already models timing (DuringResolution|LingeringPermission), so no new enum variant. The GraveyardPaidCast offer/action names are retained for frontend/serde stability (the fix is the timing derivation, not the label). Regression-green: Conduit (parser/full-card/accept/decline) and Emry (lingering control) all pass. Blast-radius parse-diff adjudication + exile/library sibling runtime tests follow. Co-Authored-By: Claude Opus 4.8 --- .../engine/src/game/effects/cast_from_zone.rs | 30 ++++++++------- crates/engine/src/parser/oracle_effect/mod.rs | 38 +++++++------------ 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 9de61dcef1..306c273ea9 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -565,9 +565,9 @@ 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. 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 + // 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` @@ -575,14 +575,18 @@ pub fn resolve( // 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). The gate no longer requires - // `mana_spend_permission.is_some()`: the only pre-existing `DuringResolution` - // graveyard producer that reaches here with `mana_spend_permission: None` is - // the new Conduit-class anaphor (`parent_target_is_graveyard_scoped`) — every - // legacy any-mana producer sets `Some(AnyTypeOrColor)`, and all free casts - // take `without_paying`, so this relaxation changes behavior for exactly the - // normal-mana normal-cost class and nothing else. - let graveyard_paid_cast = !without_paying + // 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), 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() @@ -590,8 +594,8 @@ pub fn resolve( && state .objects .get(&target_ids[0]) - .is_some_and(|o| o.zone == Zone::Graveyard); - if graveyard_paid_cast { + .is_some_and(|o| matches!(o.zone, Zone::Graveyard | 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/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index bb8fdbeaea..b1d522c6e7 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -22686,22 +22686,6 @@ fn parse_owned_plus_lesser_exiled_subject(i: &str) -> OracleResult<'_, ()> { Ok((i, ())) } -/// CR 608.2g + CR 601.2a: Is the "that card" anaphor's referent a graveyard-scoped -/// CHOSEN target (`Effect::TargetOnly` with `InZone { Graveyard }` — Conduit of -/// Worlds)? Only such a referent upgrades a *paid* "you may cast that card" to a -/// during-resolution cast; exile/hand/library chosen targets and impulse/tracked -/// anaphors (`parent_target_is_chosen == false`) keep the lingering permission. -/// Reads the zone off the chain's prior chosen target via the shared -/// `TargetFilter::extract_in_zone` building block. -fn parent_target_is_graveyard_scoped(ctx: &ParseContext) -> bool { - ctx.parent_target_is_chosen - && ctx - .chain_prior_chosen_target - .as_ref() - .and_then(TargetFilter::extract_in_zone) - == Some(crate::types::zones::Zone::Graveyard) -} - /// 1. Anaphoric — "cast it", "cast that spell", "cast those cards" — target is /// `ParentTarget` (refers to the cards exiled / chosen by a prior effect). /// 2. Constrained — "cast a [type-phrase] [from ] [with mana value ] @@ -22817,23 +22801,29 @@ fn try_parse_cast_effect(lower: &str, ctx: &ParseContext) -> Option { // the standing permission, neither of which the one-shot // during-resolution path models. // CR 608.2g: a paid, single-target, no-duration graveyard "cast that - // card" bound to a CHOSEN graveyard target is also a during-resolution - // cast (Conduit of Worlds: "Choose target nonland permanent card in your + // 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 graveyard-scoped chosen target — Emry's - // "you may cast that card THIS TURN" keeps its lingering grant because - // its `duration` is `UntilEndOfTurn`, and exile/hand/library chosen - // anaphors keep `LingeringPermission` because their zone is not the - // graveyard. + // 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 && alt_ability_cost.is_none() && duration.is_none() && constraint.is_none() - && (without_paying || parent_target_is_graveyard_scoped(ctx)) + && (without_paying || ctx.parent_target_is_chosen) { crate::types::ability::CastFromZoneDriver::DuringResolution } else { From 3ae85519f496eba4161eec41df2d41c7f271217a Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 00:19:04 -0700 Subject: [PATCH 8/9] fix(PR-6958): scope paid cast continuations --- crates/engine/src/game/casting_costs.rs | 17 ++--- crates/engine/src/game/casting_tests.rs | 63 +++++++++++++++++++ .../engine/src/game/effects/cast_from_zone.rs | 12 ++-- crates/engine/src/parser/oracle_effect/mod.rs | 21 +++---- .../engine/src/parser/oracle_effect/tests.rs | 31 +++++++++ crates/engine/src/parser/oracle_ir/context.rs | 11 ++-- crates/engine/src/types/ability.rs | 50 +++++++++++++++ 7 files changed, 172 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index ce72c2200e..fcfe846c90 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -9702,18 +9702,13 @@ fn finalize_cast_with_phyrexian_choices_inner( // 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, so propagate the signal into the stashed continuation. Gated on the - // gate's presence so the shared finalize path does not misfire: a normal hand - // cast is announced only at a priority window (no `AbilityContinuation` frame - // is stack-top there), and a during-resolution cast with no "if you do" rider - // (Cascade, Discover) carries no such gate, so neither is latched. + // 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() { - if frame.pending.chain.has_optional_effect_performed_gate() { - frame - .pending - .chain - .set_optional_effect_performed_recursive(true); - } + 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 diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 0a5265b0f5..61886977d4 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -50197,6 +50197,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(); diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 5e69deb02c..d8b7403120 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -747,7 +747,7 @@ pub fn resolve( // 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), exile, or library — rather + // 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 @@ -757,10 +757,12 @@ pub fn resolve( && alt_ability_cost.is_none() && duration.is_none() && target_ids.len() == 1 - && state - .objects - .get(&target_ids[0]) - .is_some_and(|o| matches!(o.zone, Zone::Graveyard | Zone::Exile | Zone::Library)); + && 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, diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 136138acf9..2772762350 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -23181,15 +23181,15 @@ 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 graveyard "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: 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 @@ -31554,8 +31554,7 @@ pub(crate) fn parse_effect_chain_ir( // 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. The owned filter is carried - // forward so the anaphor branch can read the chosen target's zone - // (Conduit of Worlds' paid graveyard cast); the bool is its `.is_some()`. + // 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(); diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 76f7aa759c..e73e839ab2 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -32083,6 +32083,37 @@ fn conduit_of_worlds_line2_paid_graveyard_during_resolution() { } } +#[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 diff --git a/crates/engine/src/parser/oracle_ir/context.rs b/crates/engine/src/parser/oracle_ir/context.rs index 504751cd28..3de649811a 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -269,12 +269,11 @@ pub(crate) struct ParseContext { /// 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"). Carries the target's zone so a downstream "you - /// may cast that card" anaphor can scope its cast driver: a graveyard-scoped - /// chosen target with no lingering duration is a during-resolution paid cast - /// (CR 608.2g), whereas an exile/hand/library chosen target keeps the - /// lingering permission. Seeded alongside `parent_target_is_chosen` in the - /// chunk loop; `None` on every standalone and non-chosen parse. + /// 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 diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 192f3ac9de..43163b91d3 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -25183,6 +25183,31 @@ 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 → @@ -25415,6 +25440,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: From 8e236473b6dca5a7678913901a2c425661c8a9a8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 00:56:00 -0700 Subject: [PATCH 9/9] test(PR-6958): refresh prompt census pins --- crates/engine/src/game/engine.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index fa50b0e638..c197122992 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15963,9 +15963,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`. - "game/effects/mod.rs:6228".to_string(), - "game/effects/mod.rs:6305".to_string(), - "game/effects/mod.rs:9493".to_string(), + // #6958 adds the 13-line `CastFromZone` outcome-flag exclusion above all + // three. It creates no `OptionalEffect` prompt, so the census remains five + // producers while their coordinates shift uniformly by +13. + "game/effects/mod.rs:6241".to_string(), + "game/effects/mod.rs:6318".to_string(), + "game/effects/mod.rs:9506".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.