Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 142 additions & 5 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1849,13 +1849,37 @@ fn moved_object_context_from_events(events: &[GameEvent]) -> Option<CostPaidObje
let mut moved = events.iter().filter_map(|event| match event {
GameEvent::ZoneChanged {
object_id,
from: Some(_),
from: Some(from_zone),
to,
record,
} if is_public_zone(*to) => 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()?;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// CR 608.2c: duplicate events for the same tapped creature still describe
/// one singular referent.
#[test]
Expand Down
Loading