diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index e70368da07..619066a1ed 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -2447,7 +2447,7 @@ export type GameEvent = | { type: "TurnStarted"; data: { player_id: PlayerId; turn_number: number } } | { type: "PhaseChanged"; data: { phase: Phase } } | { type: "PriorityPassed"; data: { player_id: PlayerId } } - | { type: "SpellCast"; data: { card_id: CardId; controller: PlayerId; object_id: ObjectId } } + | { type: "SpellCast"; data: { card_id: CardId; controller: PlayerId; object_id: ObjectId; cast_mana_value?: number } } | { type: "XValueChosen"; data: { player: PlayerId; object_id: ObjectId; value: number } } | { type: "AbilityActivated"; data: { player_id: PlayerId; source_id: ObjectId } } | { type: "ExhaustAbilityActivated"; data: { player_id: PlayerId; source_id: ObjectId; is_mana_ability: boolean } } diff --git a/crates/engine/src/ai_support/payment_continuation.rs b/crates/engine/src/ai_support/payment_continuation.rs index cc4c9e3481..b30ab8c25d 100644 --- a/crates/engine/src/ai_support/payment_continuation.rs +++ b/crates/engine/src/ai_support/payment_continuation.rs @@ -317,6 +317,7 @@ fn finalized_root_matches( card_id: event_card_id, controller: event_controller, object_id: event_object_id, + .. } if *event_card_id == *card_id && *event_controller == *controller && *event_object_id == *object_id diff --git a/crates/engine/src/analysis/sim.rs b/crates/engine/src/analysis/sim.rs index f3e8f25c9a..0835a669ec 100644 --- a/crates/engine/src/analysis/sim.rs +++ b/crates/engine/src/analysis/sim.rs @@ -313,6 +313,7 @@ mod tests { card_id: CardId(5), controller: PlayerId(0), object_id: ObjectId(22), + cast_mana_value: None, }, GameEvent::PhaseChanged { phase: Phase::BeginCombat, diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 9c98db6e03..5fc4ef7961 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -9771,6 +9771,13 @@ fn finalize_cast_with_phyrexian_choices_inner( card_id, controller: player, object_id, + cast_mana_value: Some( + state + .objects + .get(&object_id) + .expect("finalized spell must remain available for cast event") + .spell_mana_value(), + ), }); // CR 608.2c + CR 608.2g + CR 601.2i: A paid during-resolution cast is the diff --git a/crates/engine/src/game/effects/cascade.rs b/crates/engine/src/game/effects/cascade.rs index 4b73e76d2b..d6021329e0 100644 --- a/crates/engine/src/game/effects/cascade.rs +++ b/crates/engine/src/game/effects/cascade.rs @@ -614,6 +614,7 @@ mod tests { card_id: CardId(2000), controller: PlayerId(0), object_id: spell_id, + cast_mana_value: None, }]; let ts_before = state.next_timestamp; diff --git a/crates/engine/src/game/effects/cast_copy_of_card.rs b/crates/engine/src/game/effects/cast_copy_of_card.rs index 505a5e0ea9..11248d96c6 100644 --- a/crates/engine/src/game/effects/cast_copy_of_card.rs +++ b/crates/engine/src/game/effects/cast_copy_of_card.rs @@ -246,6 +246,13 @@ fn cast_one_copy( card_id, controller: ability.controller, object_id: copy_id, + cast_mana_value: Some( + state + .objects + .get(©_id) + .expect("cast copy must remain available for SpellCast event") + .spell_mana_value(), + ), }); if let Some(obj) = state.objects.get(©_id).cloned() { crate::game::restrictions::record_spell_cast_from_zone( diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index d8b7403120..6a2e3cba98 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -1073,6 +1073,7 @@ fn cast_stack_spell_copy_during_resolution( card_id: obj.card_id, controller: ability.controller, object_id: copy_id, + cast_mana_value: Some(obj.spell_mana_value()), }); crate::game::restrictions::record_spell_cast_from_zone( state, diff --git a/crates/engine/src/game/effects/copy_spell.rs b/crates/engine/src/game/effects/copy_spell.rs index 13451d6f9c..1a1a56102e 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -2043,6 +2043,7 @@ mod tests { card_id: CardId(1), object_id: cast_spell_id, controller: PlayerId(0), + cast_mana_value: None, }); let copy_ability = ResolvedAbility::new( @@ -2144,6 +2145,7 @@ mod tests { card_id: CardId(1), object_id: cast_spell_id, controller: PlayerId(0), + cast_mana_value: None, }), ); @@ -3360,6 +3362,7 @@ mod tests { card_id: CardId(1), object_id: ObjectId(10), controller: PlayerId(0), + cast_mana_value: None, }); let mut events = Vec::new(); resolve(&mut state, ©, &mut events).expect("automatic copy must resolve"); diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index bea3faad22..d1690d0df9 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -3113,6 +3113,7 @@ mod tests { card_id: CardId(11), controller: PlayerId(0), object_id: spell, + cast_mana_value: None, }); // The exile-until hit — Target, mana value 1. @@ -3267,6 +3268,7 @@ mod tests { card_id: CardId(11), controller: PlayerId(0), object_id: spell, + cast_mana_value: None, }); // Two exile-until hits — both bound as object targets on the parent, so the diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 0dfbe1c191..9ebbe042f3 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -3097,6 +3097,7 @@ mod tests { card_id, controller: PlayerId(0), object_id: spell, + cast_mana_value: None, }); // Demonstrative "that spell" ref with NO parent target -> event-context path. diff --git a/crates/engine/src/game/effects/discover.rs b/crates/engine/src/game/effects/discover.rs index 57de6451fd..273a9743a1 100644 --- a/crates/engine/src/game/effects/discover.rs +++ b/crates/engine/src/game/effects/discover.rs @@ -263,6 +263,7 @@ mod tests { card_id: CardId(4), controller: PlayerId(0), object_id: triggering_spell, + cast_mana_value: None, }); let ability = ResolvedAbility::new( diff --git a/crates/engine/src/game/effects/effect.rs b/crates/engine/src/game/effects/effect.rs index d51b3fd282..e68aa8d439 100644 --- a/crates/engine/src/game/effects/effect.rs +++ b/crates/engine/src/game/effects/effect.rs @@ -1380,6 +1380,7 @@ mod tests { card_id: CardId(2), controller: PlayerId(0), object_id: cast_spell, + cast_mana_value: None, }); let static_def = StaticDefinition::continuous() @@ -1538,6 +1539,7 @@ mod tests { card_id: CardId(11), controller: PlayerId(0), object_id: cast_spell, + cast_mana_value: None, }); let static_def = StaticDefinition::continuous() diff --git a/crates/engine/src/game/effects/flip_coin.rs b/crates/engine/src/game/effects/flip_coin.rs index e20ccac43d..68af5abc7c 100644 --- a/crates/engine/src/game/effects/flip_coin.rs +++ b/crates/engine/src/game/effects/flip_coin.rs @@ -1237,6 +1237,7 @@ mod tests { controller: PlayerId(0), object_id: spell_id, card_id: CardId(2), + cast_mana_value: None, }); let ability = build_resolved_from_def(execute, krark_id, PlayerId(0)); @@ -1320,6 +1321,7 @@ mod tests { controller: PlayerId(0), object_id: spell_id, card_id: CardId(2), + cast_mana_value: None, }); let ability = build_resolved_from_def(execute, krark_id, PlayerId(0)); @@ -1445,6 +1447,7 @@ mod tests { controller: PlayerId(1), object_id: ObjectId(999), card_id: CardId(2), + cast_mana_value: None, }); let ability = @@ -1484,6 +1487,7 @@ mod tests { controller: PlayerId(1), object_id: ObjectId(999), card_id: CardId(2), + cast_mana_value: None, }); // Lose branch: "that player loses 3 life" — bound to TriggeringPlayer so diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index dcb8c5a032..66fb0571d1 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -15362,6 +15362,7 @@ mod tests { card_id: CardId(99), controller: PlayerId(1), object_id: ObjectId(99), + cast_mana_value: None, }); let mut events = Vec::new(); @@ -28588,6 +28589,7 @@ mod tests { card_id: CardId(9000), controller: PlayerId(1), object_id: ObjectId(9000), + cast_mana_value: None, }); assert!( @@ -28718,6 +28720,7 @@ mod tests { card_id: CardId(9000), controller: PlayerId(0), object_id: ObjectId(9000), + cast_mana_value: None, }); // The caster (seat 0) and its teammate (seat 1) are NOT opponents. diff --git a/crates/engine/src/game/engine_priority.rs b/crates/engine/src/game/engine_priority.rs index 55f7db7a61..b42569ebd3 100644 --- a/crates/engine/src/game/engine_priority.rs +++ b/crates/engine/src/game/engine_priority.rs @@ -430,6 +430,7 @@ fn ensure_terminal_cast_spell_triggers_collected( card_id: object.card_id, controller: object.controller, object_id, + cast_mana_value: Some(object.spell_mana_value()), }; triggers::collect_triggers_into_deferred(state, &[event]); } diff --git a/crates/engine/src/game/engine_trigger_target_tests.rs b/crates/engine/src/game/engine_trigger_target_tests.rs index 6f07056a54..d39460b942 100644 --- a/crates/engine/src/game/engine_trigger_target_tests.rs +++ b/crates/engine/src/game/engine_trigger_target_tests.rs @@ -304,6 +304,7 @@ fn triggered_modal_modes_with_targets_wait_for_target_selection() { controller: PlayerId(0), object_id: ObjectId(98), card_id: CardId(98), + cast_mana_value: None, }), modal: Some(ModalChoice { min_choices: 2, @@ -584,6 +585,7 @@ fn triggered_modal_modes_without_targets_consume_pending_trigger() { controller: PlayerId(0), object_id: ObjectId(99), card_id: CardId(99), + cast_mana_value: None, }), modal: Some(ModalChoice { min_choices: 1, @@ -1077,6 +1079,7 @@ fn triggered_modal_modes_reject_unsatisfiable_target_constraints() { controller: PlayerId(0), object_id: ObjectId(97), card_id: CardId(97), + cast_mana_value: None, }), modal: Some(ModalChoice { min_choices: 2, diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index 94a43e8d6b..37c3ad3e37 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -1756,6 +1756,7 @@ mod tests { card_id: CardId(1), controller: PlayerId(0), object_id: id, + cast_mana_value: None, }; let entries = resolve_log_entries(&[event], &state, &state); assert_eq!(entries.len(), 1); diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 27360103fc..8a43804ee5 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -6002,6 +6002,42 @@ where } } +/// CR 601.2i + CR 202.3e: a `SpellCast` event's cast-time record preserves the +/// announced X value after the spell leaves the stack, where the live object's +/// mana value correctly treats X as zero. +fn spell_cast_mana_value_for_event(state: &GameState, event: &GameEvent) -> Option { + let GameEvent::SpellCast { + cast_mana_value, + controller, + object_id, + .. + } = event + else { + return None; + }; + + // CR 603.2 + CR 603.3 + CR 608.2k: the event-bound value is the authority + // for the exact cast that caused this trigger. It remains distinct when the + // same object id is cast again before an earlier trigger resolves. + if let Some(value) = cast_mana_value { + return Some(u32_to_i32_saturating(*value)); + } + + // CR 400.7: retain compatibility with legacy/synthetic events that lack the + // snapshot, but never guess between multiple same-id casts. + let mut matching_records = state + .spells_cast_this_turn_by_player + .get(controller) + .into_iter() + .flat_map(|records| records.iter()) + .filter(|record| record.spell_object_id == Some(*object_id)); + let record = matching_records.next()?; + matching_records + .next() + .is_none() + .then(|| u32_to_i32_saturating(record.mana_value)) +} + /// CR 202.3: Resolve an object's mana value through the same ObjectScope axis /// used for power/toughness. Source scope falls back to LKI for objects that /// moved during resolution; target scope reads the selected object target. @@ -6174,10 +6210,17 @@ fn resolve_object_mana_value( // instruction-order (608.2c) first, vs. cost referent (608.2k) first. // `Demonstrative` ("that spell's mana value", Mana Drain) shares this // resolution — same earlier-instruction referent named by a noun phrase. - // CR 202.3e: include cost_x_paid for on-stack event sources. + // CR 202.3e: include cost_x_paid for on-stack event sources. For a + // SpellCast event, prefer the CR 601.2i cast-time record so this value + // remains correct after the spell leaves the stack. ObjectScope::Anaphoric | ObjectScope::Demonstrative => ability .and_then(|a| a.effect_context_object.as_ref()) .map(|s| u32_to_i32_saturating(s.lki.mana_value)) + .or_else(|| { + current_or_detection_trigger_event(state) + .as_ref() + .and_then(|event| spell_cast_mana_value_for_event(state, event)) + }) .or_else(|| { object_id_for_scope(state, ObjectScope::EventSource, ctx, targets).and_then(|id| { state @@ -13557,6 +13600,7 @@ mod tests { card_id: CardId(11), controller: PlayerId(0), object_id: triggering_spell, + cast_mana_value: None, }); let expr = QuantityExpr::Ref { @@ -13948,6 +13992,82 @@ mod tests { ); } + /// CR 603.2 + CR 603.3 + CR 608.2k + CR 202.3e: an event-bound cast-time mana value must win over + /// later same-id history records, while a legacy event with no bound value + /// must not guess between ambiguous records. + #[test] + fn event_bound_spell_mana_value_survives_same_id_recast() { + let spell_id = ObjectId(77); + let mut state = GameState::new_two_player(42); + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![ + SpellCastRecord { + mana_value: 6, + spell_object_id: Some(spell_id), + ..SpellCastRecord::default() + }, + SpellCastRecord { + mana_value: 4, + spell_object_id: Some(spell_id), + ..SpellCastRecord::default() + }, + ]), + ); + let ability = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 0 }, + target: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(1), + PlayerId(0), + ); + let expr = QuantityExpr::Ref { + qty: QuantityRef::ObjectManaValue { + scope: ObjectScope::Demonstrative, + }, + }; + + state.current_trigger_event = Some(GameEvent::SpellCast { + card_id: CardId(77), + controller: PlayerId(0), + object_id: spell_id, + cast_mana_value: Some(6), + }); + assert_eq!( + resolve_quantity_with_targets(&state, &expr, &ability), + 6, + "the earlier trigger must use its event-bound cast value, not the later X" + ); + + state.current_trigger_event = Some(GameEvent::SpellCast { + card_id: CardId(77), + controller: PlayerId(0), + object_id: spell_id, + cast_mana_value: None, + }); + assert_eq!( + resolve_quantity_with_targets(&state, &expr, &ability), + 0, + "an ambiguous legacy event must fail closed rather than guess a history record" + ); + + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![SpellCastRecord { + mana_value: 5, + spell_object_id: Some(spell_id), + ..SpellCastRecord::default() + }]), + ); + assert_eq!( + resolve_quantity_with_targets(&state, &expr, &ability), + 5, + "an unambiguous legacy event should retain its history fallback" + ); + } + #[test] fn half_rounded_up_even() { let state = GameState::new_two_player(42); @@ -14481,6 +14601,7 @@ mod tests { card_id: CardId(2), controller: PlayerId(0), object_id: target, + cast_mana_value: None, }); let event_source_expr = QuantityExpr::Ref { qty: QuantityRef::ObjectColorCount { @@ -15731,9 +15852,9 @@ mod tests { } /// CR 608.2c vs CR 608.2k — divergent priority pin: when both slots are - /// populated, `Anaphoric` reads `effect_context_object` (608.2c) while - /// `CostPaidObject` reads `cost_paid_object` (608.2k). This is the test - /// that locks the two arms' priority split. + /// populated, `Anaphoric` and `Demonstrative` read `effect_context_object` + /// (608.2c) while `CostPaidObject` reads `cost_paid_object` (608.2k). This + /// is the test that locks the two arms' priority split. #[test] fn resolve_object_mana_value_anaphoric_vs_cost_paid_divergent_priority() { use crate::types::ability::{CostPaidObjectSnapshot, ResolvedAbility}; @@ -15781,6 +15902,11 @@ mod tests { scope: ObjectScope::Anaphoric, }, }; + let demonstrative = QuantityExpr::Ref { + qty: QuantityRef::ObjectManaValue { + scope: ObjectScope::Demonstrative, + }, + }; let cost_paid = QuantityExpr::Ref { qty: QuantityRef::ObjectManaValue { scope: ObjectScope::CostPaidObject, @@ -15791,6 +15917,11 @@ mod tests { 7, "Anaphoric must read effect_context_object (CR 608.2c slot 1)" ); + assert_eq!( + resolve_quantity_with_targets(&state, &demonstrative, &ability), + 7, + "Demonstrative must read effect_context_object (CR 608.2c slot 1)" + ); assert_eq!( resolve_quantity_with_targets(&state, &cost_paid, &ability), 3, diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index c2b40851ad..e6234d09ce 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -2310,6 +2310,11 @@ impl<'a> SpellCast<'a> { // distinct targets while a single declaration remains reusable across // independent modal slots. let mut remaining_objects: Vec = target_objects; + // CR 603.3d: triggered-ability targets are chosen after the trigger is + // put on the stack, independently of the spell's own target slots. + // Keep a separate object-intent pool so the same declared object can + // satisfy a trigger target and a later resolution target. + let mut remaining_trigger_objects = remaining_objects.clone(); let declared_players: Vec = target_players; let mut remaining_multi_target_players = declared_players.clone(); let mut remaining_cost_objects: Vec = cost_objects; @@ -2554,6 +2559,29 @@ impl<'a> SpellCast<'a> { &mut events, )?; } + // CR 603.3d: triggered abilities choose targets after they are + // put on the stack. Their object intents are independent of + // the spell's target slots, while player intents remain + // reusable across both prompts. + WaitingFor::TriggerTargetSelection { + target_slots, + selection, + .. + } => { + let slot = &target_slots[selection.current_slot]; + let choice = pick_slot_target( + slot, + &mut remaining_trigger_objects, + None, + &declared_players, + selection.current_slot, + ); + act_collect( + runner, + GameAction::ChooseTarget { target: choice }, + &mut events, + )?; + } // CR 601.2a: spell is on the stack — capture the hand baseline. WaitingFor::Priority { .. } => { hand_at_commit = Some( @@ -2644,6 +2672,22 @@ impl<'a> CastCommit<'a> { &self.runner.state } + /// Submit an action while this cast remains committed on the stack. + /// + /// This keeps response tests on the same `apply()` pipeline as the live + /// game, while preserving the committed cast's hand and target baselines. + pub fn act(&mut self, action: GameAction) -> Result { + self.runner.act(action) + } + + /// Start another fluent cast while this committed spell waits on the stack. + /// + /// Used by response tests to cast a counterspell or other instant before + /// resolving the committed spell and its triggers. + pub fn cast(&mut self, spell: ObjectId) -> SpellCast<'_> { + self.runner.cast(spell) + } + /// Mutate the board WHILE the committed spell is still on the stack. /// /// The spell has been announced (CR 601.2a-i) but not resolved (CR 608.2), which diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index 235ffe83ad..5ae8ec32b0 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -2814,6 +2814,7 @@ mod tests { card_id: CardId(1), object_id: spell_id, controller: PlayerId(0), + cast_mana_value: None, }); assert_eq!( resolve_event_context_target(&state, &TargetFilter::StackSpell, ObjectId(20)), diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 65aaf1535a..0c9c63abb6 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -6545,6 +6545,7 @@ mod tests { card_id: CardId(10), controller: PlayerId(0), object_id: ObjectId(10), + cast_mana_value: None, }; assert!(match_play_card( &spell_event, @@ -6618,6 +6619,7 @@ mod tests { card_id: CardId(10), controller: PlayerId(1), object_id: ObjectId(10), + cast_mana_value: None, }; assert!(!match_play_card( &opponent_spell, @@ -7037,6 +7039,7 @@ mod tests { card_id: CardId(2), controller: PlayerId(1), object_id: ObjectId(99), + cast_mana_value: None, }, &trigger, &test_trigger_source_context(&state, source), @@ -9946,6 +9949,7 @@ mod tests { card_id: CardId(10), controller: PlayerId(0), object_id: ObjectId(10), + cast_mana_value: None, }; assert!(match_spell_cast( &event, @@ -10019,6 +10023,7 @@ mod tests { card_id: CardId(100), controller: opponent, object_id: spell_id, + cast_mana_value: None, }; assert!(!match_spell_cast( &event, @@ -10057,6 +10062,7 @@ mod tests { card_id: CardId(100), controller: opponent, object_id: spell_id, + cast_mana_value: None, }; assert!(match_spell_cast( &event, @@ -10091,6 +10097,7 @@ mod tests { card_id: CardId(100), controller: caster, object_id: gy_id, + cast_mana_value: None, }; assert!(match_spell_cast( &event, @@ -10106,6 +10113,7 @@ mod tests { card_id: CardId(100), controller: caster, object_id: hand_id, + cast_mana_value: None, }; assert!(!match_spell_cast( &event, diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 7734860a56..69cf44e93a 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -15805,6 +15805,7 @@ pub mod tests { card_id: CardId(2), controller: player, object_id: spell, + cast_mana_value: None, }], ); @@ -17280,6 +17281,7 @@ pub mod tests { card_id: CardId(10), controller: PlayerId(0), object_id: spell, + cast_mana_value: None, }]; process_triggers(&mut state, &events); @@ -17333,6 +17335,7 @@ pub mod tests { card_id: CardId(10), controller: PlayerId(0), object_id: creature_spell, + cast_mana_value: None, }]; process_triggers(&mut state, &events); @@ -17386,6 +17389,7 @@ pub mod tests { card_id: CardId(10), controller: PlayerId(1), object_id: spell, + cast_mana_value: None, }]; process_triggers(&mut state, &events); @@ -18567,6 +18571,7 @@ pub mod tests { controller, object_id: source, card_id: CardId(0x98), + cast_mana_value: None, }), modal: Some(ModalChoice { min_choices: 1, @@ -20452,6 +20457,7 @@ pub mod tests { card_id: CardId(1), controller: PlayerId(0), object_id: spell_id, + cast_mana_value: None, }]; process_triggers(&mut state, &events); @@ -25542,6 +25548,7 @@ pub mod tests { card_id: CardId(2), controller: PlayerId(0), object_id: spell, + cast_mana_value: None, }; // 2 mana spent: 2 > 3 false, 2 > 4 false — trigger does NOT fire. @@ -25643,6 +25650,7 @@ pub mod tests { card_id: CardId(2), controller: PlayerId(1), object_id: spell, + cast_mana_value: None, }; state @@ -25688,6 +25696,7 @@ pub mod tests { card_id: CardId(2), controller: PlayerId(1), object_id: spell, + cast_mana_value: None, }; state @@ -25746,6 +25755,7 @@ pub mod tests { card_id: CardId(1), controller: PlayerId(0), object_id: ObjectId(1000), + cast_mana_value: None, }; // Case A: first qualifying spell — record has exactly one X-cost cast. @@ -25994,6 +26004,7 @@ pub mod tests { card_id: CardId(1), controller: PlayerId(0), object_id: opponent_spell, + cast_mana_value: None, }], ); assert!( @@ -26018,6 +26029,7 @@ pub mod tests { card_id: CardId(2), controller: PlayerId(1), object_id: controller_spell, + cast_mana_value: None, }], ); @@ -27759,6 +27771,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(2), + cast_mana_value: None, }], ); @@ -27804,6 +27817,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(1), + cast_mana_value: None, }], ); @@ -27866,6 +27880,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(2), + cast_mana_value: None, }], ); @@ -27909,6 +27924,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(1), + cast_mana_value: None, }], ); @@ -27987,6 +28003,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(2), + cast_mana_value: None, }], ); @@ -28043,6 +28060,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(1), + cast_mana_value: None, }], ); @@ -28083,6 +28101,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(1), + cast_mana_value: None, }], ); @@ -28121,6 +28140,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(1), + cast_mana_value: None, }], ); @@ -28195,6 +28215,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(2), + cast_mana_value: None, }], ); @@ -28282,6 +28303,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(2), + cast_mana_value: None, }], ); @@ -28364,6 +28386,7 @@ pub mod tests { object_id: spell, controller: caster, card_id: CardId(1), + cast_mana_value: None, }], ); @@ -38084,6 +38107,7 @@ pub mod tests { card_id: CardId(999), object_id: ObjectId(999), controller, + cast_mana_value: None, }; // Attacking: the copy trigger lands on the stack. diff --git a/crates/engine/src/game/triggers_dedup_regression_tests.rs b/crates/engine/src/game/triggers_dedup_regression_tests.rs index dcba2fabc6..2b519abc19 100644 --- a/crates/engine/src/game/triggers_dedup_regression_tests.rs +++ b/crates/engine/src/game/triggers_dedup_regression_tests.rs @@ -206,6 +206,7 @@ fn spell_cast_observer_fires_once_per_event() { card_id: CardId(4), controller: PlayerId(0), object_id: spell, + cast_mana_value: None, }; process_triggers(&mut state, &[event]); @@ -1379,6 +1380,7 @@ fn veyran_doubles_trigger_caused_by_controller_casting_instant() { card_id: CardId(200), controller: PlayerId(0), object_id: spell, + cast_mana_value: None, }; process_triggers(&mut state, &[event]); @@ -1475,6 +1477,7 @@ fn veyran_does_not_double_opponent_cast_trigger() { card_id: CardId(201), controller: PlayerId(1), object_id: spell, + cast_mana_value: None, }; process_triggers(&mut state, &[event]); diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 0f293a0bf6..508846b0f5 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1602,6 +1602,7 @@ pub fn filter_events_for_viewer( card_id: CardId(0), controller: *controller, object_id: *object_id, + cast_mana_value: None, } } other => other.clone(), @@ -2657,16 +2658,19 @@ mod tests { card_id: CardId(701), controller: PlayerId(1), object_id: face_down_spell, + cast_mana_value: Some(4), }, GameEvent::SpellCast { card_id: CardId(702), controller: PlayerId(0), object_id: own_spell, + cast_mana_value: Some(4), }, GameEvent::SpellCast { card_id: CardId(703), controller: PlayerId(1), object_id: opponent_face_up_spell, + cast_mana_value: Some(4), }, ]; @@ -2678,18 +2682,23 @@ mod tests { card_id: CardId(0), controller: PlayerId(1), object_id, + cast_mana_value: None, }, GameEvent::SpellCast { card_id: CardId(702), controller: PlayerId(0), - .. + object_id: own_object_id, + cast_mana_value: Some(4), }, GameEvent::SpellCast { card_id: CardId(703), controller: PlayerId(1), object_id: face_up_object_id, + cast_mana_value: Some(4), }, - ] if *object_id == face_down_spell && *face_up_object_id == opponent_face_up_spell + ] if *object_id == face_down_spell + && *own_object_id == own_spell + && *face_up_object_id == opponent_face_up_spell )); let spectator = filter_events_for_viewer(&events, &state, PlayerId(u8::MAX)); diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index 1787333044..de9877a956 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -773,6 +773,11 @@ pub enum GameEvent { card_id: CardId, controller: PlayerId, object_id: ObjectId, // CR 601.2a: The spell object on the stack + /// CR 202.3e + CR 601.2i: Mana value while this cast was on the stack, + /// including the announced value of X. Optional for legacy and + /// synthetic events that do not carry cast-time characteristics. + #[serde(default, skip_serializing_if = "Option::is_none")] + cast_mana_value: Option, }, /// CR 702.140c + CR 730.2: A mutating creature spell merged with a target /// creature, forming a mutated permanent. Emitted by diff --git a/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs b/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs index efcc74ee8c..55ce55d579 100644 --- a/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs +++ b/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs @@ -304,6 +304,7 @@ fn setup_breeches_runtime(seed: u64) -> (GameState, ObjectId, ObjectId) { controller: PlayerId(0), object_id: spell, card_id: CardId(3), + cast_mana_value: None, }); let parsed = parse_breeches(); diff --git a/crates/engine/tests/integration/heartwood_storyteller_opponents_draw.rs b/crates/engine/tests/integration/heartwood_storyteller_opponents_draw.rs index d70d76bb65..3ead44364d 100644 --- a/crates/engine/tests/integration/heartwood_storyteller_opponents_draw.rs +++ b/crates/engine/tests/integration/heartwood_storyteller_opponents_draw.rs @@ -80,6 +80,7 @@ fn fire_cast_trigger( card_id: CardId(9000), controller: caster, object_id: ObjectId(9000), + cast_mana_value: None, }); let parsed = parse_oracle_text( ORACLE, diff --git a/crates/engine/tests/integration/issue_2376_pyromancers_ascension.rs b/crates/engine/tests/integration/issue_2376_pyromancers_ascension.rs index bbd008c245..1a695aaf0b 100644 --- a/crates/engine/tests/integration/issue_2376_pyromancers_ascension.rs +++ b/crates/engine/tests/integration/issue_2376_pyromancers_ascension.rs @@ -113,6 +113,7 @@ fn spell_cast_event(spell_id: ObjectId) -> GameEvent { card_id: CardId(spell_id.0), controller: P0, object_id: spell_id, + cast_mana_value: None, } } diff --git a/crates/engine/tests/integration/issue_3294_good_king_mog_chapter_ii.rs b/crates/engine/tests/integration/issue_3294_good_king_mog_chapter_ii.rs index c803a7a5d6..d855147962 100644 --- a/crates/engine/tests/integration/issue_3294_good_king_mog_chapter_ii.rs +++ b/crates/engine/tests/integration/issue_3294_good_king_mog_chapter_ii.rs @@ -214,6 +214,7 @@ fn check_delayed_triggers_matches_noncreature_spell_cast_directly() { card_id: CardId(99), controller: P0, object_id: spell, + cast_mana_value: None, }; let stacked = check_delayed_triggers(state, &[spell_cast]); assert!( diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 5ce7c08f5b..2adcb181b8 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -433,6 +433,19 @@ fn on_shortcut_byte_identical_to_pre_pr7_golden() { let (rest, wf) = drive_collect(&mut runner, 500); all.extend(rest); + // The golden covers event ordering and effect payloads from before + // SpellCast gained its optional cast-time snapshot. That orthogonal field + // is asserted by the Thor quantity tests, so omit it from this legacy + // byte-for-byte stream comparison. + for event in &mut all { + if let GameEvent::SpellCast { + cast_mana_value, .. + } = event + { + *cast_mana_value = None; + } + } + assert_eq!( wf, WaitingFor::GameOver { winner: Some(P0) }, @@ -442,9 +455,9 @@ fn on_shortcut_byte_identical_to_pre_pr7_golden() { life(&runner, P1) > 0, "ON: the shortcut fired early (P1 positive)" ); + let event_stream = format!("{all:?}").replace(", cast_mana_value: None", ""); assert_eq!( - format!("{all:?}"), - GOLDEN_ON, + event_stream, GOLDEN_ON, "ON: the accumulated event stream must be byte-identical to the pre-PR-7 golden — \ wrapping the reconcile body in the mode `match` must not perturb any event" ); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index bad7638943..50ab65c895 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -992,6 +992,7 @@ mod the_immortal_sun; mod the_kingpin_of_crime_combat_damage; mod the_ur_dragon_eminence; mod the_who_opponent_guess_resolution; +mod thor_god_of_thunder; mod thorna_and_twigtooth_shared_x_relay_6956; mod thought_distortion; mod thoughtweft_trample_regression; diff --git a/crates/engine/tests/integration/riku_modal_modes_chosen_cap.rs b/crates/engine/tests/integration/riku_modal_modes_chosen_cap.rs index 47e95ab4b5..84182eeb9e 100644 --- a/crates/engine/tests/integration/riku_modal_modes_chosen_cap.rs +++ b/crates/engine/tests/integration/riku_modal_modes_chosen_cap.rs @@ -211,6 +211,7 @@ fn modes_chosen_ref_resolves_off_committed_spell() { card_id, controller: P0, object_id: atarka, + cast_mana_value: None, }); let expr = QuantityExpr::Ref { @@ -259,6 +260,7 @@ fn modes_chosen_ref_reads_event_object_not_source() { card_id, controller: P0, object_id: second, + cast_mana_value: None, }); // source_id = riku (the trigger source, chosen_modes=[2]). assert_eq!( @@ -279,6 +281,7 @@ fn modes_chosen_ref_reads_event_object_not_source() { card_id: empty_card, controller: P0, object_id: first, + cast_mana_value: None, }); assert_eq!( resolve_quantity(runner.state(), &expr, P0, riku), diff --git a/crates/engine/tests/integration/thor_god_of_thunder.rs b/crates/engine/tests/integration/thor_god_of_thunder.rs new file mode 100644 index 0000000000..81a041ad80 --- /dev/null +++ b/crates/engine/tests/integration/thor_god_of_thunder.rs @@ -0,0 +1,209 @@ +//! Thor, God of Thunder — cast-time mana value for X spells. +//! +//! The trigger's "that spell's mana value" must use the value recorded when the +//! spell was cast, including announced X, rather than the off-stack printed value. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::game_state::{CastingVariant, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const THOR_ORACLE: &str = "Flying\nWhen Thor enters, exile target Equipment, instant, or sorcery card from your graveyard. Until the end of your next turn, you may play that card.\nWhenever you cast a noncreature spell, Thor deals damage equal to that spell's mana value to any target."; + +const FORTH_EORLINGAS_ORACLE: &str = "Create X 2/2 red Human Knight creature tokens with trample and haste.\nWhenever one or more creatures you control deal combat damage to one or more players this turn, you become the monarch."; + +const COUNTERSPELL_ORACLE: &str = "Counter target spell."; + +const DEVILS_PLAY_ORACLE: &str = "Devil's Play deals X damage to any target.\nFlashback {X}{R}{R}{R} (You may cast this card from your graveyard for its flashback cost. Then exile it.)"; + +#[test] +fn thor_deals_cast_time_mana_value_to_target_for_x_spell() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario + .add_creature_from_oracle(P0, "Thor, God of Thunder", 5, 5, THOR_ORACLE) + .id(); + let victim = scenario.add_creature(P1, "Target Dummy", 2, 12).id(); + let forth = scenario + .add_spell_to_hand_from_oracle(P0, "Forth Eorlingas!", false, FORTH_EORLINGAS_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::Red, ManaCostShard::White], + generic: 0, + }) + .id(); + let counterspell = scenario + .add_spell_to_hand_from_oracle(P1, "Counterspell", true, COUNTERSPELL_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Blue, ManaCostShard::Blue], + generic: 0, + }) + .id(); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::White, ObjectId(0), false, vec![]), + ], + ); + scenario.with_mana_pool( + P1, + vec![ + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ], + ); + + let mut runner = scenario.build(); + let mut committed = runner.cast(forth).x(4).target_objects(&[victim]).commit(); + + // CR 117.7 + CR 701.6a: answer the active player's priority, then cast a + // real Counterspell in response. Its normal resolution path moves Forth + // Eorlingas! to the graveyard through the replacement-aware pipeline while + // Thor's already-triggered ability remains below the counterspell. + committed + .act(engine::types::actions::GameAction::PassPriority) + .expect("P0 passes priority to the counterspell controller"); + let outcome = committed.cast(counterspell).target_object(forth).resolve(); + + let cast_record = committed + .state() + .spells_cast_this_turn_by_player + .get(&P0) + .and_then(|records| { + records + .iter() + .find(|record| record.spell_object_id == Some(forth)) + }) + .expect("cast history must retain the triggering spell after it leaves the stack"); + assert_eq!( + cast_record.mana_value, 6, + "the cast-time record must retain Forth Eorlingas!'s X=4 mana value" + ); + + assert_eq!( + outcome.damage_marked(victim), + 6, + "Thor must use Forth Eorlingas!'s cast-time mana value {{X}}{{R}}{{W}} with X=4" + ); + assert_eq!( + outcome.zone_of(forth), + Zone::Graveyard, + "the triggering spell must have left the stack by the end of the cast pipeline" + ); +} + +/// CR 400.7 + CR 603.2 + CR 603.3 + CR 608.2h + CR 202.3e + CR 702.34a: Each +/// SpellCast trigger must retain the mana value of its own cast when the same +/// card is cast again as a new object before the earlier trigger resolves. +/// This uses a legal Counterspell response and a real Flashback cast, including +/// its exile replacement, rather than mutating the stack or zones directly. +#[test] +fn thor_binds_same_object_recasts_to_their_own_cast_values() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario + .add_creature_from_oracle(P0, "Thor, God of Thunder", 5, 5, THOR_ORACLE) + .id(); + scenario.add_enchantment_from_oracle( + P0, + "Leyline of Anticipation", + "You may cast spells as though they had flash.", + ); + let first_target = scenario.add_creature(P1, "First Thor Target", 2, 20).id(); + let second_target = scenario.add_creature(P1, "Second Thor Target", 2, 20).id(); + let devil = scenario + .add_spell_to_hand_from_oracle(P0, "Devil's Play", false, DEVILS_PLAY_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::Red], + generic: 0, + }) + .id(); + let counterspell = scenario + .add_spell_to_hand_from_oracle(P1, "Counterspell", true, COUNTERSPELL_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Blue, ManaCostShard::Blue], + generic: 0, + }) + .id(); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![]), + ], + ); + scenario.with_mana_pool( + P1, + vec![ + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ], + ); + + let mut runner = scenario.build(); + let mut first_commit = runner.cast(devil).x(4).target_object(first_target).commit(); + first_commit + .act(engine::types::actions::GameAction::PassPriority) + .expect("P0 passes priority to Counterspell"); + + { + let mut counter_commit = first_commit + .cast(counterspell) + .target_object(devil) + .commit(); + while counter_commit + .state() + .stack + .iter() + .any(|entry| entry.id == counterspell) + { + assert!( + matches!( + counter_commit.state().waiting_for, + WaitingFor::Priority { .. } + ), + "Counterspell must resolve through priority, got {:?}", + counter_commit.state().waiting_for + ); + counter_commit + .act(engine::types::actions::GameAction::PassPriority) + .expect("pass priority while Counterspell resolves"); + } + } + + let outcome = first_commit + .cast(devil) + .casting_variant(CastingVariant::Flashback) + .x(1) + .target_object(second_target) + .resolve(); + + assert_eq!( + outcome.damage_marked(first_target), + 5, + "the first Thor trigger must retain Devil's Play X=4 mana value 5" + ); + assert_eq!( + outcome.damage_marked(second_target), + 3, + "the recast's Thor trigger (MV 2) plus Devil's Play X=1 must total 3" + ); + assert_eq!( + outcome.zone_of(devil), + Zone::Exile, + "the Flashback recast must use its legal exile replacement" + ); +} diff --git a/crates/phase-ai/src/bin/ai_commander.rs b/crates/phase-ai/src/bin/ai_commander.rs index 87aebca92b..2e8ddbaf70 100644 --- a/crates/phase-ai/src/bin/ai_commander.rs +++ b/crates/phase-ai/src/bin/ai_commander.rs @@ -1317,6 +1317,7 @@ mod tests { card_id: CardId(100), controller: PlayerId(0), object_id: cast_obj, + cast_mana_value: None, }], log_entries: Vec::new(), },