diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index d45b6f410b..dc2127303a 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -1184,14 +1184,12 @@ fn drain_pending_change_zone_iteration(state: &mut GameState, events: &mut Vec :6228/:6305/:9493`. // shifts combine with #6958's paid-cast outcome exclusion and // #6976's conditional-branch exclusions. None creates an - // `OptionalEffect` prompt. Re-pinned against the merged source. - "game/effects/mod.rs:6300".to_string(), - "game/effects/mod.rs:6377".to_string(), - "game/effects/mod.rs:9572".to_string(), + // `OptionalEffect` prompt. #7268 removes two lines above all three, + // preserving the same producers at `:6298/:6375/:9570`. + "game/effects/mod.rs:6298".to_string(), + "game/effects/mod.rs:6375".to_string(), + "game/effects/mod.rs:9570".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. diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 744828707f..6f0ed11c78 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -676,19 +676,7 @@ pub(crate) fn zone_move_completion_from_delivery( member: ObjectIncarnationRef, delivery_events: &[GameEvent], ) -> ZoneMoveCompletion { - if delivery_events.iter().any(|event| { - matches!( - event, - GameEvent::ZoneChanged { record, .. } - if record - .trigger_source_context() - .is_some_and(|context| context.identity.reference == member) - ) - }) { - ZoneMoveCompletion::Moved - } else { - ZoneMoveCompletion::Remained - } + PendingZoneChangeDelivery::completion_from_delivery_events(member, delivery_events) } pub(crate) enum ZoneDeliveryResult { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 5cdf9ba70c..5db8d5e62a 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3446,6 +3446,46 @@ impl PendingZoneChangeDelivery { Ok(()) } + /// CR 400.7: A zone change creates a new object, so the retained delivery + /// event slice — not current object state — identifies whether this exact + /// pre-delivery incarnation moved. + /// + /// Returns the terminal completion recorded by the delivery pipeline, or + /// derives it from this paused delivery's exact event slice once its + /// resolution has returned to the owning resume driver. + /// + /// A returned prompt is terminal for this delivery. If an older pause path + /// omitted the sidecar classification, its own retained events remain the + /// authoritative witness; do not inspect live object state or global event + /// history, either of which could observe a later incarnation. + pub fn terminal_completion_after_resume(&self) -> ZoneMoveCompletion { + self.terminal_completion.unwrap_or_else(|| { + Self::completion_from_delivery_events(self.member, &self.delivery_events) + }) + } + + /// Classifies one explicit delivery slice against its pre-delivery + /// incarnation. Callers use this when a completed move does not have a + /// separately captured terminal sidecar. + pub fn completion_from_delivery_events( + member: ObjectIncarnationRef, + delivery_events: &[GameEvent], + ) -> ZoneMoveCompletion { + if delivery_events.iter().any(|event| { + matches!( + event, + GameEvent::ZoneChanged { record, .. } + if record + .trigger_source_context() + .is_some_and(|context| context.identity.reference == member) + ) + }) { + ZoneMoveCompletion::Moved + } else { + ZoneMoveCompletion::Remained + } + } + pub fn mark_counted(&mut self) { self.count = PausedZoneChangeDeliveryCount::AlreadyCounted; } @@ -22709,6 +22749,35 @@ mod tests { ); } + #[test] + fn paused_zone_change_delivery_derives_a_terminal_outcome_after_resume() { + let member = ObjectIncarnationRef::of(ObjectId(71), 4); + let mut delivery = PendingZoneChangeDelivery::new( + member, + ProposedEvent::zone_change( + member.object_id, + Zone::Battlefield, + Zone::Graveyard, + Some(ObjectId(72)), + ), + ); + + assert_eq!( + delivery.terminal_completion_after_resume(), + ZoneMoveCompletion::Remained, + "an answered pause with no original-incarnation event completed without moving" + ); + + delivery + .record_terminal_completion(ZoneMoveCompletion::Prevented) + .expect("the explicit replacement outcome is recorded once"); + assert_eq!( + delivery.terminal_completion_after_resume(), + ZoneMoveCompletion::Prevented, + "an explicit replacement outcome remains authoritative over slice inference" + ); + } + #[test] fn persisted_batched_zone_change_pairs_migrate_in_raw_and_trusted_envelopes() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/tests/integration/issue_3252_rhythm_of_the_wild.rs b/crates/engine/tests/integration/issue_3252_rhythm_of_the_wild.rs index 18c036d94f..bb0f8f14c5 100644 --- a/crates/engine/tests/integration/issue_3252_rhythm_of_the_wild.rs +++ b/crates/engine/tests/integration/issue_3252_rhythm_of_the_wild.rs @@ -102,10 +102,18 @@ fn rhythm_of_the_wild_grants_riot_on_library_to_battlefield_put() { resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0).unwrap(); assert_riot_replacement_choice(&runner); + assert!( + runner.state().active_change_zone_frame().is_some(), + "the real ChangeZone delivery must park its resume frame at the replacement choice" + ); runner .act(GameAction::ChooseReplacement { index: 0 }) .expect("choose Riot counter"); + assert!( + runner.state().active_change_zone_frame().is_none(), + "answering the replacement choice must drain the parked ChangeZone iteration" + ); assert_eq!( runner.state().objects[&wurm].zone, Zone::Battlefield,