diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 414d1df19a..f936ffed9c 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -1849,13 +1849,37 @@ fn moved_object_context_from_events(events: &[GameEvent]) -> Option Some(CostPaidObjectSnapshot { - object_id: *object_id, - lki: lki_snapshot_from_zone_change_record(record), - }), + } if is_public_zone(*to) + // CR 400.7j: the public-destination arm above — "If an effect causes + // an object to move to a public zone, other parts of that effect can + // find that object." Findability is public-only, so a HIDDEN + // destination needs its own rules basis, below. + // + // CR 608.2h: "... if it's no longer in that zone, or if the effect + // has moved it from a public zone to a hidden zone, the effect uses + // the object's last known information." A public -> Hand move is + // exactly that clause, so a later instruction may still refer to the + // moved object — Volcanic Vision ("Return target instant or sorcery + // card from your graveyard to your hand. ~ deals damage equal to + // THAT CARD'S MANA VALUE ..."). The reference reads the move-time + // LKI snapshot, so the hidden destination is immaterial. + // + // A hidden SOURCE (a draw, Library -> Hand) is not a public -> hidden + // move, establishes no last known information, and is excluded. + // Library destinations stay excluded entirely (a shuffle/reorder + // loses the object's identity). Note the dominant newly captured + // population is ordinary bounce (Battlefield -> Hand), not only + // graveyard/exile recursion. + || (*to == Zone::Hand && is_public_zone(*from_zone)) => + { + Some(CostPaidObjectSnapshot { + object_id: *object_id, + lki: lki_snapshot_from_zone_change_record(record), + }) + } _ => None, }); let first = moved.next()?; @@ -11245,6 +11269,54 @@ mod tests { use crate::types::triggers::TriggerMode; use crate::types::zones::Zone; + // CR 608.2h (#6486): Volcanic Vision — "Return target instant or sorcery card + // from your graveyard to your hand. ~ deals damage equal to that card's mana + // value to each creature your opponents control. Exile ~." The card is + // returned to HAND (a hidden zone); "that card's mana value" must still read + // the returned card's move-time mana value, so each opponent creature takes + // that much damage. Before the fix the returned card was not bound as the + // earlier-instruction referent (only PUBLIC-zone moves were), so the damage + // resolved to 0. + #[test] + fn volcanic_vision_deals_returned_cards_mana_value_after_return_to_hand() { + use crate::game::scenario::{GameScenario, P0, P1}; + + const ORACLE: &str = "Return target instant or sorcery card from your graveyard to your hand. \ + Volcanic Vision deals damage equal to that card's mana value to each creature your opponents control. \ + Exile Volcanic Vision."; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + (0..8) + .map(|_| ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![])) + .collect(), + ); + let volcanic = scenario + .add_spell_to_hand_from_oracle(P0, "Volcanic Vision", false, ORACLE) + .id(); + // Return target: an instant in the caster's graveyard with mana value 3. + let bolt = scenario + .add_spell_to_graveyard(P0, "Fire Blast", true) + .with_mana_cost(ManaCost::generic(3)) + .id(); + let goblin = scenario.add_creature(P1, "Goblin", 2, 2).id(); + let ogre = scenario.add_creature(P1, "Ogre", 3, 3).id(); + + let mut runner = scenario.build(); + let outcome = runner.cast(volcanic).target_objects(&[bolt]).resolve(); + + outcome.assert_zone(&[bolt], Zone::Hand); + assert_eq!( + outcome.damage_marked(goblin), + 3, + "each opponent creature must take the returned card's mana value (3)" + ); + assert_eq!(outcome.damage_marked(ogre), 3); + outcome.assert_zone(&[volcanic], Zone::Exile); + } + #[test] fn search_filter_dynamic_property_axes_consume_the_tracked_set() { let tracked = || QuantityExpr::Ref { @@ -11431,6 +11503,71 @@ mod tests { ); } + /// Build a bare `ZoneChanged` event carrying `mana_value` on its record, so + /// a bound referent's LKI payload can be asserted (not merely its presence). + fn zone_change_event(object_id: ObjectId, from: Zone, to: Zone, mana_value: u32) -> GameEvent { + GameEvent::ZoneChanged { + object_id, + from: Some(from), + to, + record: Box::new(ZoneChangeRecord { + mana_value, + ..ZoneChangeRecord::test_minimal(object_id, Some(from), to) + }), + } + } + + /// CR 608.2h: a draw (Library -> Hand) moves an object from a HIDDEN zone, + /// not "from a public zone to a hidden zone", so it establishes no last + /// known information and must never bind an anaphoric referent. Without this + /// exclusion every draw in the game would bind one. + #[test] + fn library_to_hand_move_establishes_no_parent_referent() { + let state = GameState::new_two_player(42); + let events = [zone_change_event(ObjectId(1), Zone::Library, Zone::Hand, 3)]; + assert!( + parent_referent_context_from_events(&state, &events).is_none(), + "a draw has a hidden source zone and must not bind a referent" + ); + } + + /// CR 608.2h: moving an object "from a public zone to a hidden zone" leaves + /// last known information a later instruction can refer to — Volcanic + /// Vision's "that card's mana value" after a Graveyard -> Hand return. + #[test] + fn graveyard_to_hand_move_binds_referent_with_move_time_mana_value() { + let state = GameState::new_two_player(42); + let events = [zone_change_event( + ObjectId(1), + Zone::Graveyard, + Zone::Hand, + 3, + )]; + let referent = parent_referent_context_from_events(&state, &events) + .expect("a public -> hidden move leaves last known information"); + assert_eq!(referent.object_id, ObjectId(1)); + assert_eq!( + referent.lki.mana_value, 3, + "the referent must carry the move-time mana value" + ); + } + + /// CR 608.2k: the singular guard still applies across the widened predicate — + /// a public -> Hand return plus a second qualifying move in the same span has + /// no single resolvable "that card". + #[test] + fn multiple_qualifying_moves_bind_no_parent_referent() { + let state = GameState::new_two_player(42); + let events = [ + zone_change_event(ObjectId(1), Zone::Graveyard, Zone::Hand, 3), + zone_change_event(ObjectId(2), Zone::Battlefield, Zone::Graveyard, 5), + ]; + assert!( + parent_referent_context_from_events(&state, &events).is_none(), + "two qualifying moves have no singular anaphoric referent" + ); + } + /// CR 608.2c: duplicate events for the same tapped creature still describe /// one singular referent. #[test]