diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 4aff3ded49..d8a62686da 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -19125,13 +19125,34 @@ impl GameState { } pub(crate) fn current_or_begin_rules_execution_node(&mut self) -> RulesExecutionNodeRef { - self.active_rules_execution_node.unwrap_or_else(|| { + self.live_active_rules_execution_node().unwrap_or_else(|| { self.resolved_rules_journal .begin_proposal() .expect("resolved-rules journal proposal ordinal overflow") }) } + /// Returns the ambient execution node only while its provenance record is + /// still retained. The journal has an intentionally shorter retention + /// window than the transient scope, so a boundary may discard a completed + /// parent before a later nested mana activation observes that scope. + fn live_active_rules_execution_node(&mut self) -> Option { + let node = self.active_rules_execution_node?; + if self.rules_execution_node_is_live(node) { + Some(node) + } else { + self.active_rules_execution_node = None; + None + } + } + + fn rules_execution_node_is_live(&self, node: RulesExecutionNodeRef) -> bool { + self.resolved_rules_journal + .nodes() + .iter() + .any(|candidate| candidate.identity == node) + } + /// CR 800.4: Begin the distinct execution node for one player leaving the /// game, so every mutation the leave sweep performs is attributed to the /// leave rather than to whatever rules work was in flight when the @@ -19154,8 +19175,9 @@ impl GameState { .get(&source_id) .map(ObjectIncarnationRef::from_object) .expect("mana ability activation source must exist"); + let parent = self.live_active_rules_execution_node(); self.resolved_rules_journal - .begin_activated_mana(source, self.active_rules_execution_node) + .begin_activated_mana(source, parent) .expect("resolved-rules journal settlement ordinal overflow") } @@ -19168,12 +19190,11 @@ impl GameState { trigger: Option, caused_by: Option, ) -> RulesExecutionNodeRef { + let parent = caused_by + .filter(|node| self.rules_execution_node_is_live(*node)) + .or_else(|| self.live_active_rules_execution_node()); self.resolved_rules_journal - .begin_triggered_mana( - source, - trigger, - caused_by.or(self.active_rules_execution_node), - ) + .begin_triggered_mana(source, trigger, parent) .expect("resolved-rules journal settlement ordinal overflow") } @@ -22527,7 +22548,7 @@ mod tests { }; use crate::types::deterministic_serde::test_support::ReverseBuildHasher; use crate::types::identifiers::{ - DelayedTriggerInstanceId, DelayedTriggerOrigin, DelayedTriggerToken, + CardId, DelayedTriggerInstanceId, DelayedTriggerOrigin, DelayedTriggerToken, }; use crate::types::resolved_commands::ResolvedDelayedTriggerCommand; @@ -22553,6 +22574,72 @@ mod tests { } } + #[test] + fn mana_journal_ignores_a_stale_nested_parent_after_journal_reset() { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Mana Source".to_string(), + Zone::Battlefield, + ); + let source = state + .objects + .get(&source_id) + .map(ObjectIncarnationRef::from_object) + .expect("test source exists"); + + let mut parent = None; + for _ in 0..7 { + state.active_rules_execution_node = parent; + let node = state.begin_activated_mana_journal_node(source_id); + assert_eq!( + state + .resolved_rules_journal + .nodes() + .iter() + .find(|candidate| candidate.identity == node) + .expect("activation node is retained") + .caused_by, + parent, + "a live nested activation keeps its exact parent" + ); + parent = Some(node); + } + let stale_parent = parent.expect("deep chain creates a terminal parent"); + + state.resolved_rules_journal = Default::default(); + state.active_rules_execution_node = Some(stale_parent); + let fresh_activation = state.begin_activated_mana_journal_node(source_id); + assert_eq!( + state + .resolved_rules_journal + .nodes() + .iter() + .find(|candidate| candidate.identity == fresh_activation) + .expect("fresh activation node is retained") + .caused_by, + None, + "a journal reset must not retain a parent ordinal it no longer owns" + ); + + state.active_rules_execution_node = Some(stale_parent); + let fresh_trigger = + state.begin_triggered_mana_journal_node(source, None, Some(stale_parent)); + assert_eq!( + state + .resolved_rules_journal + .nodes() + .iter() + .find(|candidate| candidate.identity == fresh_trigger) + .expect("fresh trigger node is retained") + .caused_by, + None, + "triggered mana also rejects an absent explicit or ambient parent" + ); + } + #[test] fn persisted_batched_zone_change_pairs_migrate_in_raw_and_trusted_envelopes() { let mut state = GameState::new_two_player(42);