diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index a87389d603..f959ab1cb0 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -3408,6 +3408,7 @@ mod tests { attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, is_suspected: false, }), }; diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 2b30f8200c..7fb6e53adb 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1162,12 +1162,12 @@ pub fn apply_resolved_token_creation( // directly above, which runs before this snapshot exactly as the live paths // do. Storing the live record on the command would not close (i) or (ii) // either, for the same ordering reason, so it was not done. - let entry_record = state + let mut entry_record = state .objects .get(&object_id) .expect("the token was materialized above") .snapshot_for_zone_change(object_id, None, Zone::Battlefield); - crate::game::restrictions::record_zone_change(state, entry_record); + crate::game::restrictions::record_zone_change(state, &mut entry_record); // CR 111.1: replay must not hand the same id out again to a later allocation. state.next_object_id = state.next_object_id.max(command.resulting_next_object_id); // CR 613.7d: the birth drew an entry timestamp alongside the object id, and diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 2a255d5f1d..6b9c16964b 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -2420,6 +2420,7 @@ pub fn matches_target_filter_on_lki_snapshot( attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, // CR 701.60b: Carry suspected status from the LKI snapshot so // `FilterProp::Suspected` reads the cost-paid look-back value. is_suspected: lki.is_suspected, @@ -12824,6 +12825,7 @@ mod tests { attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, is_suspected: false, }; let goblin_filter = make_subtype_filter("Goblin"); diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index ab4190d09e..974f19aeb1 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1981,6 +1981,7 @@ impl GameObject { // battlefield-entry incarnation bump; `None` here (pre-entry snapshot). entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, // CR 701.60b: Snapshot suspected status at the moment of the move, // before `move_to_zone` resets the live flag — so an LTB / cost-paid // look-back ("the sacrificed creature was suspected") reads it. diff --git a/crates/engine/src/game/meld.rs b/crates/engine/src/game/meld.rs index 97729cdcf8..9135534fc0 100644 --- a/crates/engine/src/game/meld.rs +++ b/crates/engine/src/game/meld.rs @@ -583,6 +583,7 @@ fn refresh_meld_entry_records( realized.entered_incarnation = old.entered_incarnation; realized.attached_to = old.attached_to; realized.turn_zone_change_index = old.turn_zone_change_index; + realized.recorded_turn_number = old.recorded_turn_number; realized.combat_status = combat_status; realized.sync_trigger_source_context(); *record = realized; diff --git a/crates/engine/src/game/merge.rs b/crates/engine/src/game/merge.rs index 621fdf3870..bb3bfa0ece 100644 --- a/crates/engine/src/game/merge.rs +++ b/crates/engine/src/game/merge.rs @@ -683,9 +683,7 @@ pub(crate) fn put_component_into_zone( } } - let turn_zone_change_index = - crate::game::restrictions::record_zone_change(state, record.clone()); - record.turn_zone_change_index = turn_zone_change_index; + crate::game::restrictions::record_zone_change(state, &mut record); events.push(GameEvent::ZoneChanged { object_id: component_id, from: None, diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index bc88b06e83..72aaa40164 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -605,13 +605,14 @@ pub(crate) fn ledger_filter_is_evaluable(filter: &TargetFilter) -> bool { /// Returns the per-turn zone-change index assigned to this record. pub fn record_zone_change( state: &mut crate::types::game_state::GameState, - mut record: crate::types::game_state::ZoneChangeRecord, + record: &mut crate::types::game_state::ZoneChangeRecord, ) -> usize { let object_id = record.object_id; let to_zone = record.to_zone; let turn_zone_change_index = state.zone_changes_this_turn.len(); + record.recorded_turn_number = state.turn_number; record.turn_zone_change_index = turn_zone_change_index; - state.zone_changes_this_turn.push_back(record); + state.zone_changes_this_turn.push_back(record.clone()); if to_zone == Zone::Battlefield { record_battlefield_entry(state, object_id); diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 7a22e3e19a..9663c44359 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -3919,6 +3919,7 @@ fn zone_change_record_from_spec( attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, // A freshly created token is never suspected (CR 701.60b). is_suspected: false, } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 5ecc34534a..88a4d90d9a 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -493,7 +493,7 @@ enum TriggerCollectionOperation { }, RecordBatchedZoneChanges { definition_ref: TriggerDefinitionRef, - turn_zone_change_indices: Vec, + turn_zone_change_keys: Vec<(u32, usize)>, }, AllocateTimestamp, RecordCombatDamageCastingPermission { @@ -562,11 +562,13 @@ impl TriggerCollectionSession { let Some(definition_ref) = matched.definition_ref.clone() else { return; }; - let turn_zone_change_indices = matched + let turn_zone_change_keys = matched .trigger_events .iter() .filter_map(|event| match event { - GameEvent::ZoneChanged { record, .. } => Some(record.turn_zone_change_index), + GameEvent::ZoneChanged { record, .. } => { + Some((record.recorded_turn_number, record.turn_zone_change_index)) + } _ => None, }) .collect(); @@ -574,7 +576,7 @@ impl TriggerCollectionSession { state, TriggerCollectionOperation::RecordBatchedZoneChanges { definition_ref, - turn_zone_change_indices, + turn_zone_change_keys, }, ); } @@ -674,12 +676,14 @@ impl TriggerCollectionSession { } TriggerCollectionOperation::RecordBatchedZoneChanges { definition_ref, - turn_zone_change_indices, + turn_zone_change_keys, } => { - for turn_zone_change_index in turn_zone_change_indices { - state - .batched_zone_change_trigger_fired - .insert((definition_ref.clone(), turn_zone_change_index)); + for (recorded_turn_number, turn_zone_change_index) in turn_zone_change_keys { + state.batched_zone_change_trigger_fired.insert(( + definition_ref.clone(), + recorded_turn_number, + turn_zone_change_index, + )); } None } @@ -1691,17 +1695,19 @@ fn batched_zone_change_already_collected( .iter() .filter_map(|event| { if let GameEvent::ZoneChanged { record, .. } = event { - Some(record.turn_zone_change_index) + Some((record.recorded_turn_number, record.turn_zone_change_index)) } else { None } }) .peekable(); zone_changes.peek().is_some() - && zone_changes.all(|turn_zone_change_index| { - state - .batched_zone_change_trigger_fired - .contains(&(definition_ref.clone(), turn_zone_change_index)) + && zone_changes.all(|(recorded_turn_number, turn_zone_change_index)| { + state.batched_zone_change_trigger_fired.contains(&( + definition_ref.clone(), + recorded_turn_number, + turn_zone_change_index, + )) }) } @@ -1715,9 +1721,11 @@ fn record_batched_zone_change_collected( }; for event in trigger_events { if let GameEvent::ZoneChanged { record, .. } = event { - state - .batched_zone_change_trigger_fired - .insert((definition_ref.clone(), record.turn_zone_change_index)); + state.batched_zone_change_trigger_fired.insert(( + definition_ref.clone(), + record.recorded_turn_number, + record.turn_zone_change_index, + )); } } } diff --git a/crates/engine/src/game/triggers_dedup_regression_tests.rs b/crates/engine/src/game/triggers_dedup_regression_tests.rs index 7cfa7dc534..dcba2fabc6 100644 --- a/crates/engine/src/game/triggers_dedup_regression_tests.rs +++ b/crates/engine/src/game/triggers_dedup_regression_tests.rs @@ -3713,6 +3713,118 @@ fn zone_change_count(events: &[GameEvent]) -> usize { .count() } +fn recorded_zone_change_event(state: &mut GameState, object_id: ObjectId) -> GameEvent { + let mut event = zone_change_event(object_id); + let GameEvent::ZoneChanged { record, .. } = &mut event else { + unreachable!("zone_change_event always returns ZoneChanged"); + }; + crate::game::restrictions::record_zone_change(state, record); + event +} + +#[test] +fn deferred_zone_change_witness_does_not_alias_the_next_turns_ledger_index() { + let mut state = setup(); + let old_turn = state.turn_number; + let old_event = recorded_zone_change_event(&mut state, ObjectId(7)); + state + .deferred_triggers + .push(queued_context_for(old_event.clone())); + + assert!( + filter_already_collected_trigger_events_from( + &state, + std::slice::from_ref(&old_event), + 0, + &[] + ) + .is_empty(), + "the same-turn queued witness must suppress its own occurrence" + ); + + crate::game::turns::start_next_turn(&mut state, &mut Vec::new()); + let new_event = recorded_zone_change_event(&mut state, ObjectId(7)); + let GameEvent::ZoneChanged { record, .. } = &new_event else { + unreachable!("recorded helper always returns ZoneChanged"); + }; + assert_eq!(record.turn_zone_change_index, 0); + assert_eq!( + filter_already_collected_trigger_events_from( + &state, + std::slice::from_ref(&new_event), + 0, + &[], + ), + vec![new_event], + "a deferred witness from turn {old_turn} must not consume index 0 from turn {}", + state.turn_number + ); +} + +#[test] +fn batched_zone_change_replay_guard_keeps_old_turn_markers_distinct_from_new_index_zero() { + let (mut state, observer) = setup_with_observer(TriggerMode::ChangesZone); + let (definition, definition_ref) = { + let object = state.objects.get_mut(&observer).unwrap(); + object.trigger_definitions[0].definition.batched = true; + let definition = object.trigger_definitions[0].definition.clone(); + let definition_ref = object.trigger_definition_ref(&object.trigger_definitions[0]); + (definition, definition_ref) + }; + let old_event = recorded_zone_change_event(&mut state, ObjectId(7)); + + assert!(batched_zone_change_replay_guard_applies( + &definition, + std::slice::from_ref(&old_event) + )); + record_batched_zone_change_collected( + &mut state, + Some(&definition_ref), + std::slice::from_ref(&old_event), + ); + assert!( + batched_zone_change_already_collected( + &state, + Some(&definition_ref), + std::slice::from_ref(&old_event), + ), + "the same-turn marker must suppress the event it recorded" + ); + + let GameEvent::ZoneChanged { record, .. } = &old_event else { + unreachable!("recorded helper always returns ZoneChanged"); + }; + let old_key = ( + definition_ref.clone(), + record.recorded_turn_number, + record.turn_zone_change_index, + ); + crate::game::turns::start_next_turn(&mut state, &mut Vec::new()); + state.batched_zone_change_trigger_fired.insert(old_key); + assert!( + batched_zone_change_already_collected( + &state, + Some(&definition_ref), + std::slice::from_ref(&old_event), + ), + "a retained marker must still suppress its old-turn event after the boundary" + ); + + let new_event = recorded_zone_change_event(&mut state, ObjectId(7)); + let GameEvent::ZoneChanged { record, .. } = &new_event else { + unreachable!("recorded helper always returns ZoneChanged"); + }; + assert_eq!(record.turn_zone_change_index, 0); + assert!( + !batched_zone_change_already_collected( + &state, + Some(&definition_ref), + std::slice::from_ref(&new_event), + ), + "a new-turn index-0 event must not alias the retained old-turn marker" + ); +} + /// U1 — the queued witness is COUNT-LIMITED, not set membership. /// /// Two byte-identical `ZoneChanged` in the slice against ONE queued context diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index b5fe9310fb..b073449c7c 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -824,6 +824,7 @@ pub fn resolve_and_apply_zone_change( zone_change_record.entered_incarnation = (to == Zone::Battlefield).then_some(resulting_incarnation); zone_change_record.turn_zone_change_index = turn_zone_change_index; + zone_change_record.recorded_turn_number = state.turn_number; let command = ResolvedZoneChangeCommand { object: occurrence, @@ -888,6 +889,14 @@ pub fn apply_resolved_zone_change( }, ); } + if command.zone_change_record.recorded_turn_number != state.turn_number { + return Err( + ResolvedZoneChangeReplayInvariantError::RecordedTurnMismatch { + expected: command.zone_change_record.recorded_turn_number, + found: state.turn_number, + }, + ); + } let destination_position = destination_position_after_removal( state, @@ -941,8 +950,9 @@ pub fn apply_resolved_zone_change( state.adopt_replayed_timestamp(entry_timestamp); } + let mut zone_change_record = command.zone_change_record.clone(); let turn_zone_change_index = - super::restrictions::record_zone_change(state, command.zone_change_record.clone()); + super::restrictions::record_zone_change(state, &mut zone_change_record); if turn_zone_change_index != command.turn_zone_change_index { return Err( ResolvedZoneChangeReplayInvariantError::TurnRecordIndexMismatch { @@ -1347,9 +1357,7 @@ pub fn move_to_zone( } if !transition_recorded { - let turn_zone_change_index = - super::restrictions::record_zone_change(state, zone_change_record.clone()); - zone_change_record.turn_zone_change_index = turn_zone_change_index; + super::restrictions::record_zone_change(state, &mut zone_change_record); } if let Some(old_target) = unattached_from { @@ -1426,7 +1434,7 @@ pub(crate) fn record_and_emit_entry_from_no_zone( .objects .get(&object_id) .map(|obj| obj.snapshot_for_zone_change(object_id, None, Zone::Battlefield))?; - record.turn_zone_change_index = super::restrictions::record_zone_change(state, record.clone()); + super::restrictions::record_zone_change(state, &mut record); events.push(GameEvent::ZoneChanged { object_id, from: None, @@ -1825,9 +1833,7 @@ pub fn move_to_library_at_index( record_resolution_source_relatch(state, object_id, pre, new); } - let turn_zone_change_index = - super::restrictions::record_zone_change(state, zone_change_record.clone()); - zone_change_record.turn_zone_change_index = turn_zone_change_index; + super::restrictions::record_zone_change(state, &mut zone_change_record); if let Some(old_target) = unattached_from { events.push(GameEvent::Unattached { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 5caa9655fa..0880a11a40 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -1504,6 +1504,11 @@ pub struct ZoneChangeRecord { /// within the same turn for batched trigger replay guards (issue #3866). #[serde(default)] pub turn_zone_change_index: usize, + /// Turn in which this zone-change record was written. Together with + /// `turn_zone_change_index`, this identifies a per-turn ledger entry even + /// after deferred trigger work crosses a turn boundary. + #[serde(default)] + pub recorded_turn_number: u32, /// CR 701.60b + CR 608.2c: Suspected status as of the zone change. Suspected /// is a battlefield-only status reset on any zone change, so a cost-paid /// look-back ("the sacrificed creature was suspected" — Agency Coroner) @@ -1703,6 +1708,7 @@ impl ZoneChangeRecord { attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, is_suspected: false, } } @@ -7601,6 +7607,424 @@ fn decode_persisted_resolution_state(value: serde_json::Value) -> Result Result<(), String> { + let state = value + .as_object_mut() + .ok_or_else(|| "persisted game state must be a JSON object".to_string())?; + let turn_number = state + .get("turn_number") + .and_then(serde_json::Value::as_u64) + .and_then(|turn| u32::try_from(turn).ok()) + .ok_or_else(|| "persisted game state has an invalid turn_number".to_string())?; + let Some(entries) = state.get_mut("batched_zone_change_trigger_fired") else { + return Ok(()); + }; + let entries = entries + .as_array_mut() + .ok_or_else(|| "batched_zone_change_trigger_fired must be an array".to_string())?; + for entry in entries { + let tuple = entry.as_array_mut().ok_or_else(|| { + "batched_zone_change_trigger_fired entries must be tuple arrays".to_string() + })?; + match tuple.len() { + 2 => tuple.insert(1, serde_json::Value::from(turn_number)), + 3 => {} + _ => { + return Err( + "batched_zone_change_trigger_fired entries must have two or three fields" + .to_string(), + ); + } + } + } + Ok(()) +} + +/// CR 400.7 + CR 603.2c: a persisted `ZoneChanged` must retain the identity of +/// the ledger occurrence that produced it. The live allocator writes the +/// current turn and the ledger position together, but historical payloads may +/// omit either field and deserialize them as zero. +/// +/// This runs against serialized `ResolutionStateWire` input before either v1 +/// legacy fields or v2 frames materialize into runtime state. The caller passes +/// any v1-only live roots; v2 frames are part of the canonical root set below. +/// Raw and Trusted persistence are both fallible at that boundary, and rebinding +/// after deserialization would be too late: callers could already observe an +/// ambiguous trigger event. +pub(crate) fn reconcile_persisted_zone_change_occurrences( + value: &mut serde_json::Value, + additional_live_event_roots: &[&str], +) -> Result<(), String> { + let state = value + .as_object_mut() + .ok_or_else(|| "persisted game state must be a JSON object".to_string())?; + let turn_number = persisted_turn_number(state)?; + let ledger = state + .entry("zone_changes_this_turn".to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| "zone_changes_this_turn must be an array".to_string())?; + + // CR 514.2 + CR 400.7: this is a strictly current-turn ledger. A canonical + // restore can expose stale fixture/cache rows only after the wire projects + // into GameState; discard them before assigning the current occurrence + // namespace. Live deferred events keep their own recorded turn separately. + // Retain the old positions so current-turn replay keys can follow their + // ledger row through this compaction. + let mut old_to_current_index = HashMap::new(); + let mut current_ledger = Vec::with_capacity(ledger.len()); + for (old_index, record) in std::mem::take(ledger).into_iter().enumerate() { + let stale = record + .get("recorded_turn_number") + .and_then(json_u32) + .is_some_and(|recorded_turn| recorded_turn != 0 && recorded_turn < turn_number); + if stale { + continue; + } + old_to_current_index.insert(old_index, current_ledger.len()); + current_ledger.push(record); + } + *ledger = current_ledger; + + let mut occurrences = Vec::with_capacity(ledger.len()); + for (index, record) in ledger.iter_mut().enumerate() { + let record = record + .as_object_mut() + .ok_or_else(|| "zone_changes_this_turn entries must be objects".to_string())?; + match record.get("recorded_turn_number") { + Some(recorded_turn) if json_u32(recorded_turn) == Some(turn_number) => {} + Some(recorded_turn) if json_u32(recorded_turn) == Some(0) => {} + Some(recorded_turn) if json_u32(recorded_turn).is_none() => { + return Err("zone_changes_this_turn has an invalid recorded turn".to_string()); + } + Some(_) => { + return Err("zone_changes_this_turn contains a future-turn record".to_string()); + } + None => {} + } + record.insert( + "recorded_turn_number".to_string(), + serde_json::Value::from(turn_number), + ); + record.insert( + "turn_zone_change_index".to_string(), + serde_json::Value::from(index), + ); + occurrences.push(PersistedZoneChangeOccurrence { + index, + fingerprint: zone_change_fingerprint(record)?, + }); + } + + reindex_persisted_batched_zone_change_trigger_keys(state, turn_number, &old_to_current_index)?; + + let mut seen = HashMap::<(u32, usize), serde_json::Value>::new(); + visit_persisted_live_zone_changed_records(state, additional_live_event_roots, &mut |record| { + reconcile_persisted_zone_changed_record(record, turn_number, &occurrences, &mut seen) + })?; + + Ok(()) +} + +/// CR 603.2c: the replay guard's occurrence key is a current-turn ledger +/// position. Compacting stale rows changes that position, so current-turn keys +/// must follow their exact row before typed replay-key validation runs. +fn reindex_persisted_batched_zone_change_trigger_keys( + state: &mut serde_json::Map, + current_turn: u32, + old_to_current_index: &HashMap, +) -> Result<(), String> { + let Some(entries) = state.get_mut("batched_zone_change_trigger_fired") else { + return Ok(()); + }; + let entries = entries + .as_array_mut() + .ok_or_else(|| "batched_zone_change_trigger_fired must be an array".to_string())?; + for entry in entries { + let tuple = entry.as_array_mut().ok_or_else(|| { + "batched_zone_change_trigger_fired entries must be tuple arrays".to_string() + })?; + let [_, recorded_turn, index] = tuple.as_mut_slice() else { + return Err( + "batched_zone_change_trigger_fired entries must have three fields".to_string(), + ); + }; + if json_u32(recorded_turn) == Some(current_turn) { + let old_index = json_usize(index).ok_or_else(|| { + "batched_zone_change_trigger_fired occurrence index must be an integer".to_string() + })?; + let new_index = old_to_current_index.get(&old_index).ok_or_else(|| { + "batched_zone_change_trigger_fired current-turn key points to a pruned ledger row" + .to_string() + })?; + *index = serde_json::Value::from(*new_index); + } + } + Ok(()) +} + +#[derive(Debug)] +struct PersistedZoneChangeOccurrence { + index: usize, + fingerprint: serde_json::Value, +} + +fn persisted_turn_number( + state: &serde_json::Map, +) -> Result { + state + .get("turn_number") + .and_then(json_u32) + .ok_or_else(|| "persisted game state has an invalid turn_number".to_string()) +} + +fn json_u32(value: &serde_json::Value) -> Option { + value.as_u64().and_then(|value| u32::try_from(value).ok()) +} + +fn json_usize(value: &serde_json::Value) -> Option { + value.as_u64().and_then(|value| usize::try_from(value).ok()) +} + +fn zone_change_fingerprint( + record: &serde_json::Map, +) -> Result { + if !record.contains_key("object_id") + || !record.contains_key("from_zone") + || !record.contains_key("to_zone") + { + return Err("ZoneChanged record is missing its zone-change shape".to_string()); + } + let mut fingerprint = record.clone(); + fingerprint.remove("turn_zone_change_index"); + fingerprint.remove("recorded_turn_number"); + Ok(serde_json::Value::Object(fingerprint)) +} + +fn reconcile_persisted_zone_changed_record( + record: &mut serde_json::Value, + current_turn: u32, + occurrences: &[PersistedZoneChangeOccurrence], + seen: &mut HashMap<(u32, usize), serde_json::Value>, +) -> Result<(), String> { + let record = record + .as_object_mut() + .ok_or_else(|| "ZoneChanged record must be an object".to_string())?; + let fingerprint = zone_change_fingerprint(record)?; + let recorded_turn = record.get("recorded_turn_number").and_then(json_u32); + let index = record.get("turn_zone_change_index").and_then(json_usize); + + let (recorded_turn, index) = match recorded_turn { + Some(0) if current_turn != 0 => reconcile_current_turn_zone_changed_record( + record, + current_turn, + &fingerprint, + occurrences, + )?, + Some(turn) if turn < current_turn => { + let index = index.ok_or_else(|| { + "prior-turn ZoneChanged record is missing its occurrence index".to_string() + })?; + (turn, index) + } + Some(turn) if turn > current_turn => { + return Err("ZoneChanged record is stamped from a future turn".to_string()); + } + Some(turn) if turn == current_turn => { + if let Some(index) = index { + if occurrences + .get(index) + .is_some_and(|occurrence| occurrence.fingerprint == fingerprint) + { + (turn, index) + } else { + reconcile_current_turn_zone_changed_record( + record, + current_turn, + &fingerprint, + occurrences, + )? + } + } else { + reconcile_current_turn_zone_changed_record( + record, + current_turn, + &fingerprint, + occurrences, + )? + } + } + // Predates `recorded_turn_number`: historical active-resolution + // carriers can retain an event absent from their old per-turn ledger. + // It has no authoritative occurrence key to validate or invent, so + // preserve serde's `(0, index)` compatibility representation. Explicit + // zero/current stamps remain strict below. + None if !occurrences + .iter() + .any(|occurrence| occurrence.fingerprint == fingerprint) => + { + return Ok(()) + } + None => reconcile_current_turn_zone_changed_record( + record, + current_turn, + &fingerprint, + occurrences, + )?, + Some(_) => unreachable!("future turns return above"), + }; + + match seen.entry((recorded_turn, index)) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(fingerprint); + } + std::collections::hash_map::Entry::Occupied(entry) if *entry.get() == fingerprint => {} + std::collections::hash_map::Entry::Occupied(_) => { + return Err("ZoneChanged occurrence key is shared by conflicting records".to_string()); + } + } + Ok(()) +} + +/// A coherent pair can remain as-is even when two historical ledger rows are +/// byte-identical: persistence cannot distinguish copied carriers of one +/// occurrence from a lost second occurrence. Only a record that needs a new +/// binding must identify exactly one ledger row. +fn reconcile_current_turn_zone_changed_record( + record: &mut serde_json::Map, + current_turn: u32, + fingerprint: &serde_json::Value, + occurrences: &[PersistedZoneChangeOccurrence], +) -> Result<(u32, usize), String> { + let candidates = occurrences + .iter() + .filter(|occurrence| occurrence.fingerprint == *fingerprint) + .collect::>(); + let [occurrence] = candidates.as_slice() else { + return Err(match candidates.len() { + 0 => "current-turn ZoneChanged record has no ledger occurrence".to_string(), + _ => "ZoneChanged record ambiguously matches multiple ledger occurrences".to_string(), + }); + }; + record.insert( + "recorded_turn_number".to_string(), + serde_json::Value::from(current_turn), + ); + record.insert( + "turn_zone_change_index".to_string(), + serde_json::Value::from(occurrence.index), + ); + Ok((current_turn, occurrence.index)) +} + +/// Visits `ZoneChanged` records in live event carriers only. The occurrence +/// ledger is consumed by active trigger, stack, prompt, and continuation work; +/// append-only journal entries are historical evidence and must not be rebound +/// to the current turn's ledger. +fn visit_persisted_live_zone_changed_records( + state: &mut serde_json::Map, + additional_live_event_roots: &[&str], + visit: &mut impl FnMut(&mut serde_json::Value) -> Result<(), String>, +) -> Result<(), String> { + // The resolution stack contains paused delivery and continuation event + // contexts. Keep this list at GameState's live-carrier boundary: + // recursively visiting the whole state would also rewrite + // `resolved_rules_journal` snapshots. + const LIVE_EVENT_CARRIER_FIELDS: &[&str] = &[ + "deferred_entry_events", + "pending_trigger", + "pending_trigger_event_batch", + "deferred_triggers", + "pending_trigger_order", + "consumed_before_priority_trigger_events", + "pending_attack_trigger_events", + "pending_player_scope_sacrifice_choice", + "stack", + "waiting_for", + "resolution_stack", + "current_trigger_event", + "resolving_stack_entry", + "current_trigger_events", + "stack_trigger_event_batches", + "pending_cost_move_resume", + "pending_deferred_life_cost_resume", + "pending_discard_for_cost", + // ResolutionStateWire's current representation holds active frames + // here, never in legacy top-level continuation fields. + "resolution_frames", + ]; + + for field in LIVE_EVENT_CARRIER_FIELDS + .iter() + .copied() + .chain(additional_live_event_roots.iter().copied()) + { + if let Some(value) = state.get_mut(field) { + visit_persisted_zone_changed_records_in_value(value, visit)?; + } + } + Ok(()) +} + +/// Visits only actual serialized `GameEvent::ZoneChanged` payloads within one +/// known-live carrier. The externally-tagged form is retained for current state +/// snapshots; accepting the internally-tagged form keeps the migration +/// compatible with wire payloads written by older event codecs. +fn visit_persisted_zone_changed_records_in_value( + value: &mut serde_json::Value, + visit: &mut impl FnMut(&mut serde_json::Value) -> Result<(), String>, +) -> Result<(), String> { + match value { + serde_json::Value::Array(values) => { + for value in values { + visit_persisted_zone_changed_records_in_value(value, visit)?; + } + } + serde_json::Value::Object(object) => { + if let Some(record) = serialized_zone_changed_record_mut(object) { + visit(record)?; + return Ok(()); + } + for value in object.values_mut() { + visit_persisted_zone_changed_records_in_value(value, visit)?; + } + } + _ => {} + } + Ok(()) +} + +fn serialized_zone_changed_record_mut( + value: &mut serde_json::Map, +) -> Option<&mut serde_json::Value> { + if value.get("type").and_then(serde_json::Value::as_str) == Some("ZoneChanged") { + let data = value.get_mut("data")?.as_object_mut()?; + return data.get_mut("record"); + } + let data = value.get_mut("ZoneChanged")?.as_object_mut()?; + data.get_mut("record") +} + +fn validate_restored_zone_change_replay_keys(state: &GameState) -> Result<(), String> { + for (_, recorded_turn, index) in &state.batched_zone_change_trigger_fired { + if *recorded_turn != state.turn_number { + return Err("batched zone-change replay key is from another turn".to_string()); + } + let record = state.zone_changes_this_turn.get(*index).ok_or_else(|| { + "batched zone-change replay key points outside the current ledger".to_string() + })?; + if record.recorded_turn_number != *recorded_turn || record.turn_zone_change_index != *index + { + return Err( + "batched zone-change replay key disagrees with the current ledger".to_string(), + ); + } + } + Ok(()) +} + fn delayed_install_origins(state: &GameState) -> impl Iterator + '_ { state .resolved_rules_journal @@ -14409,13 +14833,13 @@ declare_game_state! { #[serde(default, skip_serializing_if = "im::Vector::is_empty")] pub zone_changes_this_turn: im::Vector, /// CR 603.2c: Batched zone-change triggers already collected for - /// `(definition_ref, turn_zone_change_index)`. Prevents a second + /// `(definition_ref, recorded_turn_number, turn_zone_change_index)`. Prevents a second /// `process_triggers` pass over the same `ZoneChanged` events from /// stacking duplicate batched triggers (issue #3866) without suppressing a /// later distinct leave by the same object in the same turn. #[serde(default, skip_serializing_if = "HashSet::is_empty")] #[serde(serialize_with = "crate::types::deterministic_serde::hash_set")] - pub batched_zone_change_trigger_fired: HashSet<(TriggerDefinitionRef, usize)>, + pub batched_zone_change_trigger_fired: HashSet<(TriggerDefinitionRef, u32, usize)>, /// CR 403.3: Battlefield entry snapshots this turn, enabling data-driven ETB queries. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub battlefield_entries_this_turn: Vec, @@ -15236,9 +15660,11 @@ impl GameStateDecode { return Err("invalid persisted resolution-state decode mode".to_string()); } } + migrate_legacy_batched_zone_change_trigger_fired(&mut value)?; let mut state = serde_json::from_value::(value) .map(ResolutionStateWire::into_game_state) .map_err(|error| error.to_string())?; + validate_restored_zone_change_replay_keys(&state)?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; reject_zero_bound_shortcut_offer(&state)?; @@ -21745,6 +22171,55 @@ mod tests { } } + #[test] + fn persisted_batched_zone_change_pairs_migrate_in_raw_and_trusted_envelopes() { + let mut state = GameState::new_two_player(42); + state.turn_number = 19; + for index in 0..=3 { + state + .zone_changes_this_turn + .push_back(persisted_zone_change_record( + ObjectId(9_200 + index as u64), + state.turn_number, + index, + )); + } + let definition_ref = printed_trigger_ref(0); + state.batched_zone_change_trigger_fired.insert(( + definition_ref.clone(), + state.turn_number, + 3, + )); + + let raw = serde_json::to_value(PersistedGameState::Raw(Box::new(state.clone()))) + .expect("raw fixture serializes"); + let trusted = serde_json::to_value(PersistedGameState::capture(state)) + .expect("trusted fixture serializes"); + + for mut persisted in [raw, trusted] { + let state = if persisted.get("state").is_some() { + persisted + .get_mut("state") + .expect("trusted envelope contains state") + } else { + &mut persisted + }; + state["batched_zone_change_trigger_fired"][0] + .as_array_mut() + .expect("marker serializes as a tuple array") + .remove(1); + + let restored = serde_json::from_value::(persisted) + .expect("legacy pair marker migrates at the persistence boundary") + .into_game_state(); + assert!(restored.batched_zone_change_trigger_fired.contains(&( + definition_ref.clone(), + 19, + 3 + ))); + } + } + #[test] fn special_map_serializers_sort_typed_keys_without_changing_wire_shapes() { let mut tuple_values = HashMap::with_hasher(ReverseBuildHasher); @@ -22160,6 +22635,571 @@ mod tests { state } + #[test] + fn deferred_zone_change_event_roundtrips_and_defaults_its_recorded_turn() { + let mut state = normal_trigger_firing_fixture(); + let event = GameEvent::ZoneChanged { + object_id: ObjectId(9_004), + from: Some(Zone::Library), + to: Zone::Battlefield, + record: Box::new(ZoneChangeRecord { + recorded_turn_number: 19, + turn_zone_change_index: 3, + ..ZoneChangeRecord::test_minimal( + ObjectId(9_004), + Some(Zone::Library), + Zone::Battlefield, + ) + }), + }; + state.deferred_triggers[0].pending.trigger_event = Some(event.clone()); + state.deferred_triggers[0].trigger_events = vec![event]; + + let wire = serde_json::to_value(&state).expect("deferred-event fixture serializes"); + let restored: GameState = serde_json::from_value(wire.clone()) + .expect("nested deferred zone-change event round-trips"); + let GameEvent::ZoneChanged { record, .. } = + &restored.deferred_triggers[0].trigger_events[0] + else { + panic!("deferred fixture retains its zone-change event"); + }; + assert_eq!(record.recorded_turn_number, 19); + + let mut legacy_wire = wire; + legacy_wire["deferred_triggers"][0]["trigger_events"][0]["data"]["record"] + .as_object_mut() + .expect("nested record is an object") + .remove("recorded_turn_number"); + let legacy: GameState = serde_json::from_value(legacy_wire) + .expect("legacy nested record without a turn defaults safely"); + let GameEvent::ZoneChanged { record, .. } = &legacy.deferred_triggers[0].trigger_events[0] + else { + panic!("legacy deferred fixture retains its zone-change event"); + }; + assert_eq!(record.recorded_turn_number, 0); + } + + fn persisted_zone_change_record( + object_id: ObjectId, + turn: u32, + index: usize, + ) -> ZoneChangeRecord { + ZoneChangeRecord { + recorded_turn_number: turn, + turn_zone_change_index: index, + ..ZoneChangeRecord::test_minimal(object_id, Some(Zone::Battlefield), Zone::Graveyard) + } + } + + fn persisted_zone_change_event(record: ZoneChangeRecord) -> GameEvent { + GameEvent::ZoneChanged { + object_id: record.object_id, + from: record.from_zone, + to: record.to_zone, + record: Box::new(record), + } + } + + fn persisted_state_payload_mut(value: &mut serde_json::Value) -> &mut serde_json::Value { + if value.get("state").is_some() { + value + .get_mut("state") + .expect("trusted fixture has an inner state") + } else { + value + } + } + + fn erase_persisted_event_occurrence_fields(value: &mut serde_json::Value) { + let mut erased = 0; + let state = value + .as_object_mut() + .expect("persisted fixture has a state object"); + visit_persisted_live_zone_changed_records(state, &[], &mut |record| { + let record = record + .as_object_mut() + .expect("serialized ZoneChanged record is an object"); + record.remove("recorded_turn_number"); + record.remove("turn_zone_change_index"); + erased += 1; + Ok(()) + }) + .expect("fixture event traversal succeeds"); + assert!( + erased > 0, + "fixture contains a serialized ZoneChanged event" + ); + } + + fn restored_deferred_zone_change_keys(state: &GameState) -> Vec<(u32, usize)> { + state.deferred_triggers[0] + .trigger_events + .iter() + .map(|event| match event { + GameEvent::ZoneChanged { record, .. } => { + (record.recorded_turn_number, record.turn_zone_change_index) + } + _ => panic!("fixture stores only ZoneChanged events"), + }) + .collect() + } + + #[test] + fn persisted_zone_change_events_reconcile_in_raw_and_trusted_envelopes() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let first = persisted_zone_change_record(ObjectId(9_101), 19, 0); + let second = persisted_zone_change_record(ObjectId(9_102), 19, 1); + state.zone_changes_this_turn.push_back(first.clone()); + state.zone_changes_this_turn.push_back(second.clone()); + state.deferred_triggers[0].trigger_events = vec![ + persisted_zone_change_event(first), + persisted_zone_change_event(second), + ]; + + let raw = serde_json::to_value(PersistedGameState::Raw(Box::new(state.clone()))) + .expect("raw fixture serializes"); + let trusted = serde_json::to_value(PersistedGameState::capture(state)) + .expect("trusted fixture serializes"); + + for mut persisted in [raw, trusted] { + erase_persisted_event_occurrence_fields(persisted_state_payload_mut(&mut persisted)); + let restored = serde_json::from_value::(persisted) + .expect("unique legacy event records reconcile") + .into_game_state(); + assert_eq!( + restored_deferred_zone_change_keys(&restored), + vec![(19, 0), (19, 1)] + ); + } + } + + #[test] + fn persisted_zone_change_collision_rebinds_only_unique_ledger_records() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let first = persisted_zone_change_record(ObjectId(9_111), 19, 0); + let mut second = persisted_zone_change_record(ObjectId(9_112), 19, 0); + second.name = "Distinct second occurrence".to_string(); + state.zone_changes_this_turn.push_back(first.clone()); + state.zone_changes_this_turn.push_back(second.clone()); + state.deferred_triggers[0].trigger_events = vec![ + persisted_zone_change_event(first), + persisted_zone_change_event(second), + ]; + + let persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("fixture serializes"); + let restored = serde_json::from_value::(persisted) + .expect("unique records repair a stale collision") + .into_game_state(); + assert_eq!( + restored_deferred_zone_change_keys(&restored), + vec![(19, 0), (19, 1)] + ); + assert_eq!( + restored + .zone_changes_this_turn + .iter() + .map(|record| record.turn_zone_change_index) + .collect::>(), + vec![0, 1] + ); + } + + #[test] + fn persisted_zone_change_preserves_stamped_prior_turn_event() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let current = persisted_zone_change_record(ObjectId(9_121), 19, 0); + let prior = persisted_zone_change_record(ObjectId(9_121), 18, 0); + state.zone_changes_this_turn.push_back(current); + state.deferred_triggers[0].trigger_events = vec![persisted_zone_change_event(prior)]; + + let persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("fixture serializes"); + let restored = serde_json::from_value::(persisted) + .expect("stamped prior-turn event remains valid") + .into_game_state(); + assert_eq!(restored_deferred_zone_change_keys(&restored), vec![(18, 0)]); + } + + #[test] + fn persisted_zone_change_prunes_stale_ledger_rows_before_rebinding_live_events() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let stale = persisted_zone_change_record(ObjectId(9_126), 18, 4); + let current = persisted_zone_change_record(ObjectId(9_127), 19, 1); + state.zone_changes_this_turn.push_back(stale); + state.zone_changes_this_turn.push_back(current.clone()); + state.deferred_triggers[0].trigger_events = vec![persisted_zone_change_event(current)]; + state.batched_zone_change_trigger_fired.insert(( + printed_trigger_ref(1), + state.turn_number, + 1, + )); + + let mut persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("fixture serializes"); + erase_persisted_event_occurrence_fields(persisted_state_payload_mut(&mut persisted)); + let restored = serde_json::from_value::(persisted) + .expect("stale ledger history is pruned before live event reconciliation") + .into_game_state(); + + assert_eq!(restored.zone_changes_this_turn.len(), 1); + assert_eq!( + ( + restored.zone_changes_this_turn[0].recorded_turn_number, + restored.zone_changes_this_turn[0].turn_zone_change_index, + ), + (19, 0), + "the retained current-turn ledger row receives the current namespace" + ); + assert_eq!(restored_deferred_zone_change_keys(&restored), vec![(19, 0)]); + assert!(restored.batched_zone_change_trigger_fired.contains(&( + printed_trigger_ref(1), + 19, + 0 + ))); + } + + #[test] + fn persisted_zone_change_rejects_replay_key_for_pruned_stale_ledger_row() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + state + .zone_changes_this_turn + .push_back(persisted_zone_change_record(ObjectId(9_128), 18, 0)); + state + .zone_changes_this_turn + .push_back(persisted_zone_change_record(ObjectId(9_129), 19, 1)); + state.batched_zone_change_trigger_fired.insert(( + printed_trigger_ref(2), + state.turn_number, + 0, + )); + + let persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("fixture serializes"); + let error = serde_json::from_value::(persisted) + .expect_err("a replay key for a pruned stale row cannot alias the compacted ledger"); + assert!(error.to_string().contains("pruned ledger row")); + } + + #[test] + fn persisted_zone_change_rejects_ambiguous_legacy_event_in_both_envelopes() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let record = persisted_zone_change_record(ObjectId(9_131), 19, 0); + state.zone_changes_this_turn.push_back(record.clone()); + state.zone_changes_this_turn.push_back(record.clone()); + state.deferred_triggers[0].trigger_events = vec![persisted_zone_change_event(record)]; + + let raw = serde_json::to_value(PersistedGameState::Raw(Box::new(state.clone()))) + .expect("raw fixture serializes"); + let trusted = serde_json::to_value(PersistedGameState::capture(state)) + .expect("trusted fixture serializes"); + for mut persisted in [raw, trusted] { + erase_persisted_event_occurrence_fields(persisted_state_payload_mut(&mut persisted)); + let error = serde_json::from_value::(persisted) + .expect_err("ambiguous historical event must fail closed"); + assert!(error.to_string().contains("ambiguously matches")); + } + } + + #[test] + fn persisted_zone_change_rejects_future_turn_and_bad_batched_replay_key() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let record = persisted_zone_change_record(ObjectId(9_141), 19, 0); + state.zone_changes_this_turn.push_back(record.clone()); + state.deferred_triggers[0].trigger_events = vec![persisted_zone_change_event(record)]; + + let mut future = serde_json::to_value(PersistedGameState::Raw(Box::new(state.clone()))) + .expect("fixture serializes"); + let future_state = persisted_state_payload_mut(&mut future); + let future_state = future_state + .as_object_mut() + .expect("persisted fixture has a state object"); + visit_persisted_live_zone_changed_records(future_state, &[], &mut |record| { + record + .as_object_mut() + .expect("event record is an object") + .insert( + "recorded_turn_number".to_string(), + serde_json::Value::from(20), + ); + Ok(()) + }) + .expect("fixture traversal succeeds"); + let error = serde_json::from_value::(future) + .expect_err("future occurrence must fail"); + assert!(error.to_string().contains("future turn")); + + state + .batched_zone_change_trigger_fired + .insert((printed_trigger_ref(9), 19, 1)); + let persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("fixture serializes"); + let error = serde_json::from_value::(persisted) + .expect_err("out-of-range batched key must fail"); + assert!(error.to_string().contains("pruned ledger row")); + } + + #[test] + fn persisted_zone_change_preserves_unkeyed_legacy_active_resolution_event() { + let record = persisted_zone_change_record(ObjectId(9_145), 19, 0); + let mut event = serde_json::to_value(persisted_zone_change_event(record)) + .expect("fixture event serializes"); + let event_record = event["data"]["record"] + .as_object_mut() + .expect("fixture ZoneChanged event has a record"); + event_record.remove("recorded_turn_number"); + event_record.remove("turn_zone_change_index"); + + let mut state = serde_json::json!({ + "turn_number": 19, + "zone_changes_this_turn": [], + "resolving_stack_entry": { + "kind": { "data": { "trigger_event": event } } + }, + }); + reconcile_persisted_zone_change_occurrences(&mut state, &[]) + .expect("an unkeyed legacy active-resolution event remains loadable"); + + let record = state["resolving_stack_entry"]["kind"]["data"]["trigger_event"]["data"] + ["record"] + .as_object() + .expect("fixture event record remains present"); + assert!( + record.get("recorded_turn_number").is_none(), + "unmatchable legacy event must not be rebound to a fabricated current occurrence" + ); + assert!( + record.get("turn_zone_change_index").is_none(), + "unmatchable legacy event retains no invented ledger index" + ); + } + + #[test] + fn persisted_zone_change_traverses_direct_queue_and_stack_carriers() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let record = persisted_zone_change_record(ObjectId(9_151), 19, 0); + let event = persisted_zone_change_event(record.clone()); + state.zone_changes_this_turn.push_back(record); + state.deferred_entry_events = vec![event.clone()]; + state.pending_trigger_event_batch = vec![event.clone()]; + state.current_trigger_event = Some(event.clone()); + state.current_trigger_events = vec![event.clone()]; + state + .pending_trigger + .as_mut() + .expect("fixture has pending trigger") + .trigger_event = Some(event.clone()); + state.deferred_triggers[0].pending.trigger_event = Some(event.clone()); + state.deferred_triggers[0].trigger_events = vec![event.clone()]; + state + .stack_trigger_event_batches + .insert(ObjectId(9_003), vec![event.clone()]); + state.pending_player_scope_sacrifice_choice = Some(PendingPlayerScopeSacrificeChoice { + ability: state + .pending_trigger + .as_ref() + .expect("fixture has pending trigger") + .ability + .clone(), + remaining_players: Vec::new(), + selections: Vec::new(), + completion: PendingPlayerScopeSacrificeCompletion { + deferred_events: vec![event.clone()], + ..PendingPlayerScopeSacrificeCompletion::default() + }, + }); + let StackEntryKind::TriggeredAbility { trigger_event, .. } = &mut state + .stack + .back_mut() + .expect("fixture has stack entry") + .kind + else { + panic!("fixture stack entry is triggered"); + }; + *trigger_event = Some(event); + + let mut persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("fixture serializes"); + erase_persisted_event_occurrence_fields(persisted_state_payload_mut(&mut persisted)); + let restored = serde_json::from_value::(persisted) + .expect("all serialized carrier records reconcile") + .into_game_state(); + let GameEvent::ZoneChanged { record, .. } = &restored + .pending_player_scope_sacrifice_choice + .as_ref() + .expect("paused sacrifice carrier survives restoration") + .completion + .deferred_events[0] + else { + panic!("paused sacrifice carrier retains a ZoneChanged event"); + }; + assert_eq!( + (record.recorded_turn_number, record.turn_zone_change_index), + (19, 0), + "paused sacrifice deferred event is reconciled to its live ledger occurrence" + ); + + let mut restored_wire = serde_json::to_value(PersistedGameState::Raw(Box::new(restored))) + .expect("restored fixture serializes"); + let mut keys = Vec::new(); + let restored_state = persisted_state_payload_mut(&mut restored_wire) + .as_object_mut() + .expect("persisted fixture has a state object"); + visit_persisted_live_zone_changed_records(restored_state, &[], &mut |record| { + let record = record + .as_object() + .expect("serialized event record is an object"); + keys.push(( + record + .get("recorded_turn_number") + .and_then(json_u32) + .expect("carrier has recorded turn"), + record + .get("turn_zone_change_index") + .and_then(json_usize) + .expect("carrier has occurrence index"), + )); + Ok(()) + }) + .expect("carrier traversal succeeds"); + assert!(keys.len() >= 9, "fixture reaches each selected carrier"); + assert!(keys.iter().all(|key| *key == (19, 0))); + } + + #[test] + fn persisted_zone_change_traverses_all_v1_frame_event_carriers() { + let record = persisted_zone_change_record(ObjectId(9_155), 19, 0); + let mut event = serde_json::to_value(persisted_zone_change_event(record.clone())) + .expect("fixture event serializes"); + let event_record = event["data"]["record"] + .as_object_mut() + .expect("fixture ZoneChanged event has a record"); + event_record.remove("recorded_turn_number"); + event_record.remove("turn_zone_change_index"); + + // The v1 reader passes these legacy roots to the live-carrier visitor. + // Their nested shapes cover the logical owner, paused/deferred delivery, + // mill alias, and per-player trigger-context families respectively. + let mut state = serde_json::json!({ + "turn_number": 19, + "zone_changes_this_turn": [serde_json::to_value(record).expect("ledger record serializes")], + "pending_continuation": { + "trigger_context": { "event": event.clone() } + }, + "pending_choose_zone_trigger_context": { "event": event.clone() }, + "pending_optional_trigger_event": event.clone(), + "pending_change_zone_iteration": { + "logical_zone_change_group": { + "all_origin_occurrences": [{ "event": event.clone() }] + } + }, + "pending_batch_deliveries": { + "paused_current": { "delivery_events": [event.clone()] }, + "deferred_events": [event.clone()] + }, + "pending_mill_deliveries": { + "paused_current": { "delivery_events": [event.clone()] }, + "deferred_events": [event.clone()] + }, + "pending_each_player_copy_chosen": { "trigger_event": event }, + }); + let legacy_event_roots = [ + "pending_continuation", + "pending_choose_zone_trigger_context", + "pending_optional_trigger_event", + "pending_change_zone_iteration", + "pending_batch_deliveries", + "pending_mill_deliveries", + "pending_each_player_copy_chosen", + ]; + + reconcile_persisted_zone_change_occurrences(&mut state, &legacy_event_roots) + .expect("every v1 frame event carrier reconciles its ZoneChanged record"); + + let state = state.as_object_mut().expect("fixture has a state object"); + + for root in legacy_event_roots { + let mut keys = Vec::new(); + visit_persisted_zone_changed_records_in_value( + state.get_mut(root).expect("fixture contains legacy root"), + &mut |record| { + let record = record + .as_object() + .expect("serialized event record is an object"); + keys.push(( + record + .get("recorded_turn_number") + .and_then(json_u32) + .expect("legacy event receives a recorded turn"), + record + .get("turn_zone_change_index") + .and_then(json_usize) + .expect("legacy event receives an occurrence index"), + )); + Ok(()) + }, + ) + .expect("legacy carrier traversal succeeds"); + assert!( + !keys.is_empty(), + "fixture for {root} contains a serialized ZoneChanged event" + ); + assert!( + keys.iter().all(|key| *key == (19, 0)), + "every event retained by {root} is reconciled to the live ledger occurrence" + ); + } + } + + #[test] + fn persisted_zone_change_ignores_historical_journal_events_but_rejects_live_events() { + let mut state = normal_trigger_firing_fixture(); + state.turn_number = 19; + let historical = persisted_zone_change_event(persisted_zone_change_record( + ObjectId(9_161), + state.turn_number, + 0, + )); + + let mut archived_entry = state + .stack + .back() + .expect("fixture has a stack entry") + .clone(); + archived_entry.id = ObjectId(9_162); + let StackEntryKind::TriggeredAbility { trigger_event, .. } = &mut archived_entry.kind + else { + panic!("fixture stack entry is triggered"); + }; + *trigger_event = Some(historical.clone()); + crate::game::stack::push_to_stack(&mut state, archived_entry, &mut Vec::new()); + state + .stack + .pop_back() + .expect("journaled stack entry remains removable from the live stack"); + state.stack_trigger_firings.remove(&ObjectId(9_162)); + + let persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state.clone()))) + .expect("historical stack entry serializes in the resolved-rules journal"); + serde_json::from_value::(persisted) + .expect("historical journal event is not a live occurrence carrier"); + + state.deferred_entry_events = vec![historical]; + let persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("live carrier fixture serializes"); + let error = serde_json::from_value::(persisted) + .expect_err("a live current-turn event without a ledger occurrence must fail"); + assert!(error.to_string().contains("no ledger occurrence")); + } + fn trigger_continuation_fixture() -> GameState { let mut state = normal_trigger_firing_fixture(); state.resolving_stack_entry = state.stack.back().cloned(); @@ -22366,6 +23406,104 @@ mod tests { ); } + #[test] + fn v1_continuation_zone_change_event_reconciles_after_frame_projection() { + let mut state = trigger_continuation_fixture(); + state.turn_number = 19; + let record = persisted_zone_change_record(ObjectId(9_171), state.turn_number, 0); + let event = persisted_zone_change_event(record.clone()); + state.zone_changes_this_turn.push_back(record); + state.current_trigger_event = Some(event.clone()); + state.current_trigger_events = vec![event]; + + let continuation = PendingContinuation::new( + state + .pending_trigger + .as_ref() + .expect("fixture has an active trigger") + .ability + .clone(), + &state, + ); + state.resolution_stack = Box::default(); + let mut v1 = serde_json::to_value(state).expect("v1 fixture serializes"); + v1["resolution_state_version"] = serde_json::Value::from(1); + v1["pending_continuation"] = + serde_json::to_value(continuation).expect("legacy continuation serializes"); + let record = v1["pending_continuation"]["trigger_context"]["event"]["data"]["record"] + .as_object_mut() + .expect("legacy continuation has a ZoneChanged trigger event"); + record.remove("recorded_turn_number"); + record.remove("turn_zone_change_index"); + + let restored = serde_json::from_value::(v1) + .expect("legacy continuation event reconciles after frame projection") + .into_game_state(); + let GameEvent::ZoneChanged { record, .. } = &restored + .active_ability_continuation() + .expect("legacy continuation projects into the canonical frame stack") + .trigger_context + .as_ref() + .expect("continuation retains its trigger context") + .event + .as_ref() + .expect("continuation retains the triggering event") + else { + panic!("continuation trigger context retains a ZoneChanged event"); + }; + assert_eq!( + (record.recorded_turn_number, record.turn_zone_change_index), + (19, 0), + "projected continuation event is reconciled to its current ledger occurrence" + ); + } + + #[test] + fn v2_continuation_zone_change_event_reconciles_in_serialized_frame() { + let mut state = trigger_continuation_fixture(); + state.turn_number = 19; + let record = persisted_zone_change_record(ObjectId(9_172), state.turn_number, 0); + let event = persisted_zone_change_event(record.clone()); + state.zone_changes_this_turn.push_back(record); + state.current_trigger_event = Some(event.clone()); + state.current_trigger_events = vec![event]; + state.resolution_stack = Box::default(); + let continuation = PendingContinuation::new( + state + .pending_trigger + .as_ref() + .expect("fixture has an active trigger") + .ability + .clone(), + &state, + ); + state.park_ability_continuation(continuation); + + let mut persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("v2 frame fixture serializes"); + erase_persisted_event_occurrence_fields(persisted_state_payload_mut(&mut persisted)); + let restored = serde_json::from_value::(persisted) + .expect("v2 frame event reconciles before materialization") + .into_game_state(); + let GameEvent::ZoneChanged { record, .. } = &restored + .active_ability_continuation() + .expect("v2 frame restores as an active continuation") + .trigger_context + .as_ref() + .expect("continuation retains its trigger context") + .event + .as_ref() + .expect("continuation retains the triggering event") + else { + panic!("continuation trigger context retains a ZoneChanged event"); + }; + assert_eq!( + (record.recorded_turn_number, record.turn_zone_change_index), + (19, 0), + "serialized v2 frame event is reconciled to its current ledger occurrence" + ); + } + #[test] fn direct_resolution_wire_rejects_unlabeled_active_trigger_carriers() { let state = normal_trigger_firing_fixture(); diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index 715c53fe62..0cbe4effb6 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -2676,14 +2676,28 @@ impl ResolutionStateWire { // both belong to `GameStateDecode`; no wire branch gets a private // `GameState` serde shortcut. GameStateDecode::prepare_resolution_wire(&mut value, decode_mode)?; - let object = value - .as_object() - .expect("the checked resolution state wire remains an object"); match version { // V1 reader compatibility path: historical keys are consumed here // and projected into typed frames before runtime state is restored. LEGACY_RESOLUTION_STATE_WIRE_VERSION => { + crate::types::game_state::reconcile_persisted_zone_change_occurrences( + &mut value, + &[ + "pending_continuation", + "pending_choose_zone_trigger_context", + "pending_optional_trigger_event", + // These v1 frame payloads retain ZoneChanged events in + // their logical-owner, delivery, or trigger context. + "pending_change_zone_iteration", + "pending_batch_deliveries", + "pending_mill_deliveries", + "pending_each_player_copy_chosen", + ], + )?; + let object = value + .as_object() + .expect("the checked resolution state wire remains an object"); if object.contains_key("resolution_frames") { return Err("v1 resolution state must not contain resolution_frames".to_string()); } @@ -2847,6 +2861,13 @@ impl ResolutionStateWire { Ok(Self { state: legacy }) } RESOLUTION_STATE_WIRE_VERSION => { + crate::types::game_state::reconcile_persisted_zone_change_occurrences( + &mut value, + &[], + )?; + let object = value + .as_object() + .expect("the checked resolution state wire remains an object"); if legacy_resolution_wire_field(object).is_some() { return Err("v2 resolution state must not contain a legacy resolution field".to_string()); } diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index 9a0b1f9722..0b34d62075 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -899,6 +899,8 @@ pub enum ResolvedZoneChangeReplayInvariantError { DestinationPositionMismatch { expected: usize, found: usize }, #[error("zone-change turn-record index mismatch: expected {expected}, found {found}")] TurnRecordIndexMismatch { expected: usize, found: usize }, + #[error("zone-change recorded-turn mismatch: expected {expected}, found {found}")] + RecordedTurnMismatch { expected: u32, found: u32 }, #[error("zone-change battlefield entry is missing its timestamp")] MissingBattlefieldEntryTimestamp, #[error("zone-change nonbattlefield entry unexpectedly has a timestamp")] diff --git a/crates/engine/tests/integration/cr733_resolved_zone_change.rs b/crates/engine/tests/integration/cr733_resolved_zone_change.rs index e7a83a36f9..cceaa279ac 100644 --- a/crates/engine/tests/integration/cr733_resolved_zone_change.rs +++ b/crates/engine/tests/integration/cr733_resolved_zone_change.rs @@ -157,6 +157,23 @@ fn zone_change_rejects_occurrence_and_destination_position_mismatches() { )); } +#[test] +fn zone_change_replay_validates_the_nested_records_turn_number() { + let (pre_state, ordinary_state) = battlefield_reentry_states(); + let command = recorded_zone_change(&ordinary_state, Zone::Graveyard, Zone::Battlefield); + + let mut replay = pre_state.clone(); + apply_resolved_zone_change(&mut replay, &command) + .expect("an exact nested record replays successfully"); + + let mut tampered = command; + tampered.zone_change_record.recorded_turn_number += 1; + assert!(matches!( + apply_resolved_zone_change(&mut pre_state.clone(), &tampered), + Err(ResolvedZoneChangeReplayInvariantError::RecordedTurnMismatch { .. }) + )); +} + #[test] fn zone_change_journal_rejects_an_unrelated_cause() { let (_, ordinary_state) = battlefield_reentry_states(); diff --git a/crates/engine/tests/integration/issue_3277_captain_nghathrod_eliminated_opponent.rs b/crates/engine/tests/integration/issue_3277_captain_nghathrod_eliminated_opponent.rs index 75c294bc15..012f2b586b 100644 --- a/crates/engine/tests/integration/issue_3277_captain_nghathrod_eliminated_opponent.rs +++ b/crates/engine/tests/integration/issue_3277_captain_nghathrod_eliminated_opponent.rs @@ -105,6 +105,7 @@ fn captain_nghathrod_end_step_skips_eliminated_opponent_graveyard() { attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, is_suspected: false, }); diff --git a/crates/engine/tests/integration/issue_5332_gandalf_trigger_doubling.rs b/crates/engine/tests/integration/issue_5332_gandalf_trigger_doubling.rs index 08e849bc82..ec8cb3d11a 100644 --- a/crates/engine/tests/integration/issue_5332_gandalf_trigger_doubling.rs +++ b/crates/engine/tests/integration/issue_5332_gandalf_trigger_doubling.rs @@ -135,6 +135,7 @@ fn gandalf_parsed_static_doubles_legendary_reentry_triggers() { attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, is_suspected: false, }), }; diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index fec9c46a1d..2fb6e46ba1 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -67,7 +67,7 @@ const BLOODTHIRSTY_CONQUEROR: &str = /// HEAD `dc67bd130` on the UNMODIFIED reconcile body. See the module docs. /// `subject: None` was appended to each `EffectResolved` when that field was added to the /// event — it is `None` on every path this test drives, so the stream is otherwise unchanged. -const GOLDEN_ON: &str = r#"[StackPushed { object_id: ObjectId(3) }, ZoneChanged { object_id: ObjectId(3), from: Some(Hand), to: Stack, record: ZoneChangeRecord { object_id: ObjectId(3), name: "Test Lifegain Kickoff", core_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], trigger_definitions: [], trigger_source_context: Some(TriggerSourceContext { identity: ObjectIdentityBinding { reference: ObjectIncarnationRef { object_id: ObjectId(3), incarnation: 0 }, expected_zone: Hand }, lki: LKISnapshot { name: "Test Lifegain Kickoff", token_image_ref: None, power: None, toughness: None, base_power: None, base_toughness: None, mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), card_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], colors: [], chosen_attributes: [], counters: {}, tapped: false, is_suspected: false, attachments: [] }, card_id: CardId(3), printed_ref: None, is_token: false, face_down: false, transformed: false, is_renowned: false, is_saddled: false, class_level: None, trigger_entries: [], timestamp: 0, entered_battlefield_turn: None, paired_with: None, pair_controller: None, attached_to: None, attachments: [], linked_exile_snapshot: [], combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, cast_from_zone: None, played_from_zone: None, cast_controller: None, phase_status: PhasedIn, cast_variant_paid: None, cast_timing_permission: None, cost_x_paid: None, cast_spell_keywords: [], mana_spent_to_cast: false, colors_spent_to_cast: ColoredManaCount { white: 0, blue: 0, black: 0, red: 0, green: 0 }, mana_spent_to_cast_amount: 0, kickers_paid: [], additional_cost_payment_count: 0, additional_cost_payments: [], cast_cost_paid_object: None }), power: None, toughness: None, base_power: None, base_toughness: None, colors: [], mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), from_zone: Some(Hand), cast_from_zone: None, played_from_zone: None, to_zone: Stack, attachments: [], linked_exile_snapshot: [], is_token: false, combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, co_departed: [], entered_incarnation: None, attached_to: None, turn_zone_change_index: 0, is_suspected: false } }, SpellCast { card_id: CardId(3), controller: PlayerId(0), object_id: ObjectId(3) }, PriorityPassed { player_id: PlayerId(1) }, LifeChanged { player_id: PlayerId(0), amount: 1 }, EffectResolved { kind: GainLife, source_id: ObjectId(3), subject: None }, ZoneChanged { object_id: ObjectId(3), from: Some(Stack), to: Graveyard, record: ZoneChangeRecord { object_id: ObjectId(3), name: "Test Lifegain Kickoff", core_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], trigger_definitions: [], trigger_source_context: Some(TriggerSourceContext { identity: ObjectIdentityBinding { reference: ObjectIncarnationRef { object_id: ObjectId(3), incarnation: 1 }, expected_zone: Stack }, lki: LKISnapshot { name: "Test Lifegain Kickoff", token_image_ref: None, power: None, toughness: None, base_power: None, base_toughness: None, mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), card_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], colors: [], chosen_attributes: [], counters: {}, tapped: false, is_suspected: false, attachments: [] }, card_id: CardId(3), printed_ref: None, is_token: false, face_down: false, transformed: false, is_renowned: false, is_saddled: false, class_level: None, trigger_entries: [], timestamp: 0, entered_battlefield_turn: None, paired_with: None, pair_controller: None, attached_to: None, attachments: [], linked_exile_snapshot: [], combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, cast_from_zone: None, played_from_zone: None, cast_controller: None, phase_status: PhasedIn, cast_variant_paid: None, cast_timing_permission: None, cost_x_paid: None, cast_spell_keywords: [], mana_spent_to_cast: false, colors_spent_to_cast: ColoredManaCount { white: 0, blue: 0, black: 0, red: 0, green: 0 }, mana_spent_to_cast_amount: 0, kickers_paid: [], additional_cost_payment_count: 0, additional_cost_payments: [], cast_cost_paid_object: None }), power: None, toughness: None, base_power: None, base_toughness: None, colors: [], mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), from_zone: Some(Stack), cast_from_zone: None, played_from_zone: None, to_zone: Graveyard, attachments: [], linked_exile_snapshot: [], is_token: false, combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, co_departed: [], entered_incarnation: None, attached_to: None, turn_zone_change_index: 1, is_suspected: false } }, StackResolved { object_id: ObjectId(3) }, PriorityPassed { player_id: PlayerId(1) }, LifeChanged { player_id: PlayerId(1), amount: -1 }, EffectResolved { kind: LoseLife, source_id: ObjectId(1), subject: None }, StackResolved { object_id: ObjectId(4) }, PriorityPassed { player_id: PlayerId(1) }, LifeChanged { player_id: PlayerId(0), amount: 1 }, EffectResolved { kind: GainLife, source_id: ObjectId(2), subject: None }, StackResolved { object_id: ObjectId(5) }, GameOver { winner: Some(PlayerId(0)) }]"#; +const GOLDEN_ON: &str = r#"[StackPushed { object_id: ObjectId(3) }, ZoneChanged { object_id: ObjectId(3), from: Some(Hand), to: Stack, record: ZoneChangeRecord { object_id: ObjectId(3), name: "Test Lifegain Kickoff", core_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], trigger_definitions: [], trigger_source_context: Some(TriggerSourceContext { identity: ObjectIdentityBinding { reference: ObjectIncarnationRef { object_id: ObjectId(3), incarnation: 0 }, expected_zone: Hand }, lki: LKISnapshot { name: "Test Lifegain Kickoff", token_image_ref: None, power: None, toughness: None, base_power: None, base_toughness: None, mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), card_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], colors: [], chosen_attributes: [], counters: {}, tapped: false, is_suspected: false, attachments: [] }, card_id: CardId(3), printed_ref: None, is_token: false, face_down: false, transformed: false, is_renowned: false, is_saddled: false, class_level: None, trigger_entries: [], timestamp: 0, entered_battlefield_turn: None, paired_with: None, pair_controller: None, attached_to: None, attachments: [], linked_exile_snapshot: [], combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, cast_from_zone: None, played_from_zone: None, cast_controller: None, phase_status: PhasedIn, cast_variant_paid: None, cast_timing_permission: None, cost_x_paid: None, cast_spell_keywords: [], mana_spent_to_cast: false, colors_spent_to_cast: ColoredManaCount { white: 0, blue: 0, black: 0, red: 0, green: 0 }, mana_spent_to_cast_amount: 0, kickers_paid: [], additional_cost_payment_count: 0, additional_cost_payments: [], cast_cost_paid_object: None }), power: None, toughness: None, base_power: None, base_toughness: None, colors: [], mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), from_zone: Some(Hand), cast_from_zone: None, played_from_zone: None, to_zone: Stack, attachments: [], linked_exile_snapshot: [], is_token: false, combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, co_departed: [], entered_incarnation: None, attached_to: None, turn_zone_change_index: 0, recorded_turn_number: 2, is_suspected: false } }, SpellCast { card_id: CardId(3), controller: PlayerId(0), object_id: ObjectId(3) }, PriorityPassed { player_id: PlayerId(1) }, LifeChanged { player_id: PlayerId(0), amount: 1 }, EffectResolved { kind: GainLife, source_id: ObjectId(3), subject: None }, ZoneChanged { object_id: ObjectId(3), from: Some(Stack), to: Graveyard, record: ZoneChangeRecord { object_id: ObjectId(3), name: "Test Lifegain Kickoff", core_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], trigger_definitions: [], trigger_source_context: Some(TriggerSourceContext { identity: ObjectIdentityBinding { reference: ObjectIncarnationRef { object_id: ObjectId(3), incarnation: 1 }, expected_zone: Stack }, lki: LKISnapshot { name: "Test Lifegain Kickoff", token_image_ref: None, power: None, toughness: None, base_power: None, base_toughness: None, mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), card_types: [Sorcery], subtypes: [], supertypes: [], keywords: [], colors: [], chosen_attributes: [], counters: {}, tapped: false, is_suspected: false, attachments: [] }, card_id: CardId(3), printed_ref: None, is_token: false, face_down: false, transformed: false, is_renowned: false, is_saddled: false, class_level: None, trigger_entries: [], timestamp: 0, entered_battlefield_turn: None, paired_with: None, pair_controller: None, attached_to: None, attachments: [], linked_exile_snapshot: [], combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, cast_from_zone: None, played_from_zone: None, cast_controller: None, phase_status: PhasedIn, cast_variant_paid: None, cast_timing_permission: None, cost_x_paid: None, cast_spell_keywords: [], mana_spent_to_cast: false, colors_spent_to_cast: ColoredManaCount { white: 0, blue: 0, black: 0, red: 0, green: 0 }, mana_spent_to_cast_amount: 0, kickers_paid: [], additional_cost_payment_count: 0, additional_cost_payments: [], cast_cost_paid_object: None }), power: None, toughness: None, base_power: None, base_toughness: None, colors: [], mana_value: 0, controller: PlayerId(0), owner: PlayerId(0), from_zone: Some(Stack), cast_from_zone: None, played_from_zone: None, to_zone: Graveyard, attachments: [], linked_exile_snapshot: [], is_token: false, combat_status: ZoneChangeCombatStatus { attacking: false, blocking: false, blocked: false, attacking_alone: false, blocking_alone: false, defending_player: None }, co_departed: [], entered_incarnation: None, attached_to: None, turn_zone_change_index: 1, recorded_turn_number: 2, is_suspected: false } }, StackResolved { object_id: ObjectId(3) }, PriorityPassed { player_id: PlayerId(1) }, LifeChanged { player_id: PlayerId(1), amount: -1 }, EffectResolved { kind: LoseLife, source_id: ObjectId(1), subject: None }, StackResolved { object_id: ObjectId(4) }, PriorityPassed { player_id: PlayerId(1) }, LifeChanged { player_id: PlayerId(0), amount: 1 }, EffectResolved { kind: GainLife, source_id: ObjectId(2), subject: None }, StackResolved { object_id: ObjectId(5) }, GameOver { winner: Some(PlayerId(0)) }]"#; fn life(runner: &GameRunner, p: PlayerId) -> i32 { runner @@ -5295,13 +5295,14 @@ fn migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither() "migrated fixture must decode through the PRODUCTION decoder" ); - // R8 — THE ROUTING IS STATE-NEUTRAL. The six loaders stopped decoding a bare - // `GameState` and wrapping it in `PersistedGameState::Raw`, and started decoding AS + // R8 — THE ROUTING CANONICALIZES PERSISTED PROVENANCE. The six loaders stopped decoding + // a bare `GameState` and wrapping it in `PersistedGameState::Raw`, and started decoding AS // `PersistedGameState`: a different type, a different `Deserialize`, and a different // conversion (`decode_persisted_resolution_state`, which injects // `resolution_state_version` and decodes the resolution state as `ResolutionStateWire`). - // That is a real change to how six fixtures restore, and the two arms above cannot - // witness it — they assert only that a decode succeeds or fails. + // The persistence boundary assigns the current-turn occurrence namespace to the old + // ledger and matching live stack events; direct-current raw decode deliberately does not. + // Every other serialized surface must remain unchanged. // // `GameState` has no `PartialEq`, so compare its serialized forms recursively. // Hash-backed owners now serialize canonically at their field boundaries; arrays are @@ -5365,22 +5366,89 @@ fn migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither() "state", &mut diffs, ); + assert_eq!( + diffs, + vec![ + "state.stack (different elements)".to_string(), + "state.zone_changes_this_turn (different elements)".to_string(), + ], + "the production restore changes only the persisted zone-change provenance surfaces" + ); + + let stack_zone_change_keys = |state: &GameState| { + state + .stack + .iter() + .filter_map(|entry| { + let StackEntryKind::TriggeredAbility { + trigger_event: Some(GameEvent::ZoneChanged { record, .. }), + .. + } = &entry.kind + else { + return None; + }; + Some((record.recorded_turn_number, record.turn_zone_change_index)) + }) + .collect::>() + }; + let legacy_stack_keys = stack_zone_change_keys(&legacy_restored); + let routed_stack_keys = stack_zone_change_keys(&routed_restored); assert!( - diffs.is_empty(), - "routing the six dump loaders through the production decoder must restore the SAME \ - state they restored before; differing paths: {diffs:?}" + !legacy_stack_keys.is_empty(), + "reach-guard: the fixture has live stack ZoneChanged trigger contexts" + ); + assert!( + legacy_restored + .zone_changes_this_turn + .iter() + .all(|record| record.recorded_turn_number == 0) + && legacy_stack_keys.iter().all(|(turn, _)| *turn == 0), + "direct-current raw decode retains the historical zero provenance defaults" + ); + assert_eq!( + routed_restored + .zone_changes_this_turn + .iter() + .enumerate() + .map(|(index, record)| ( + record.recorded_turn_number, + record.turn_zone_change_index, + index + )) + .collect::>(), + (0..routed_restored.zone_changes_this_turn.len()) + .map(|index| (routed_restored.turn_number, index, index)) + .collect::>(), + "production restore stamps each retained ledger row in its current-turn namespace" + ); + assert!( + routed_stack_keys + .iter() + .all(|(turn, _)| *turn == routed_restored.turn_number), + "production restore stamps every matching live stack event with the current turn" + ); + assert_eq!( + routed_stack_keys + .iter() + .map(|(_, index)| *index) + .collect::>(), + legacy_stack_keys + .iter() + .map(|(_, index)| *index) + .collect::>(), + "production restore preserves each stack event's ledger occurrence identity" ); // The perturbation IS that assertion's reach-guard: without it, a comparison that // compared a value to itself — or explained every difference away as set order — would // pass on any two states at all. Perturb ONE scalar; the comparison must SEE it, and // must name the field it saw. - let mut perturbed = legacy_restored; + let mut perturbed = legacy_restored.clone(); perturbed.turn_number += 1; let mut perturbed_diffs = Vec::new(); differences( &serialized(&perturbed), - &routed_value, + &serialized(&legacy_restored), "state", &mut perturbed_diffs, ); @@ -5388,7 +5456,7 @@ fn migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither() perturbed_diffs, vec!["state.turn_number".to_string()], "the comparison must see a one-scalar difference AND name it; if it cannot, the \ - equality above proves nothing" + canonicalization-surface comparison above proves nothing" ); // NEGATIVE (the anti-vacuity control): strip the field back out and both must reject. diff --git a/crates/engine/tests/integration/madame_null_integration.rs b/crates/engine/tests/integration/madame_null_integration.rs index 711d812bc1..a25cf4e576 100644 --- a/crates/engine/tests/integration/madame_null_integration.rs +++ b/crates/engine/tests/integration/madame_null_integration.rs @@ -112,6 +112,7 @@ fn set_etb_event(state: &mut GameState, entering: ObjectId) { attached_to: None, entered_incarnation: None, turn_zone_change_index: 0, + recorded_turn_number: 0, is_suspected: false, }), });