From 8ae5ac68e4eb695727bf205c83c59b1288d309a3 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 22:14:43 -0700 Subject: [PATCH 1/3] Recover ceased token trigger snapshots --- crates/engine/src/types/game_state.rs | 118 ++++++++++++++++ .../unmaterialized_lki_serialization.rs | 126 ++++++++++++++++++ 2 files changed, 244 insertions(+) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 8f9dc2dae3..ef1f44e01b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -8004,6 +8004,8 @@ pub(crate) fn migrate_legacy_zone_change_trigger_provenance( let mut trigger_bases = HashMap::new(); (|| { + prune_ceased_source_legacy_trigger_payloads(state, objects)?; + for field in [ "created_tokens_this_turn", "sacrificed_permanents_this_turn", @@ -8052,6 +8054,122 @@ pub(crate) fn migrate_legacy_zone_change_trigger_provenance( migration } +/// CR 400.7 + CR 603.3: a ceased source cannot be reconstructed through a +/// same-id object lookup, while an already-stacked triggered ability keeps its +/// captured ability independently of that source. Historical ledgers and stack +/// entries may therefore discard only an all-payload legacy trigger list for a +/// source which no longer exists. Live event carriers remain strict below: they +/// can still need the exact trigger occurrence to finish trigger collection. +fn prune_ceased_source_legacy_trigger_payloads( + state: &mut serde_json::Map, + objects: &serde_json::Map, +) -> Result<(), String> { + for field in [ + "created_tokens_this_turn", + "sacrificed_permanents_this_turn", + "zone_changes_this_turn", + ] { + let Some(records) = state.get_mut(field) else { + continue; + }; + let records = records + .as_array_mut() + .ok_or_else(|| format!("{field} must be an array"))?; + for record in records { + prune_ceased_source_legacy_trigger_payload(record, objects)?; + } + } + + for field in ["stack", "resolving_stack_entry"] { + let Some(entries) = state.get_mut(field) else { + continue; + }; + visit_persisted_triggered_stack_entry_records(entries, &mut |record| { + prune_ceased_source_legacy_trigger_payload(record, objects) + })?; + } + Ok(()) +} + +fn prune_ceased_source_legacy_trigger_payload( + record: &mut serde_json::Value, + objects: &serde_json::Map, +) -> Result<(), String> { + let record = record + .as_object_mut() + .ok_or_else(|| "persisted zone-change record must be an object".to_string())?; + if !matches!( + record.get("trigger_source_context"), + Some(serde_json::Value::Null) | None + ) { + return Ok(()); + } + let object_id: ObjectId = record + .get("object_id") + .cloned() + .ok_or_else(|| "persisted zone-change record has no object id".to_string()) + .and_then(|value| serde_json::from_value(value).map_err(|error| error.to_string()))?; + if objects.contains_key(&object_id.0.to_string()) { + return Ok(()); + } + let entries = record + .get("trigger_definitions") + .cloned() + .map(serde_json::from_value::>) + .transpose() + .map_err(|error| error.to_string())? + .unwrap_or_default(); + if legacy_trigger_entry_list(&entries).map_err(str::to_string)? { + record.insert( + "trigger_definitions".to_string(), + serde_json::Value::Array(Vec::new()), + ); + } + Ok(()) +} + +/// Visits `ZoneChanged` records held by a current triggered stack entry. This +/// intentionally does not recurse through generic live carriers: only a +/// triggered ability already on the stack has completed trigger collection. +fn visit_persisted_triggered_stack_entry_records( + value: &mut serde_json::Value, + visit: &mut impl FnMut(&mut serde_json::Value) -> Result<(), String>, +) -> Result<(), String> { + match value { + serde_json::Value::Array(entries) => { + for entry in entries { + visit_persisted_triggered_stack_entry_records(entry, visit)?; + } + } + serde_json::Value::Object(entry) => { + let Some(kind) = entry + .get_mut("kind") + .and_then(serde_json::Value::as_object_mut) + else { + return Ok(()); + }; + if kind.get("type").and_then(serde_json::Value::as_str) != Some("TriggeredAbility") { + return Ok(()); + } + let Some(event) = kind + .get_mut("data") + .and_then(serde_json::Value::as_object_mut) + .and_then(|data| data.get_mut("trigger_event")) + else { + return Ok(()); + }; + let Some(event) = event.as_object_mut() else { + return Ok(()); + }; + if let Some(record) = serialized_zone_changed_record_mut(event) { + visit(record)?; + } + } + _ => {} + } + Ok(()) +} + /// The only persisted object fields that can prove a context-free legacy /// record was one of its initial printed trigger slots. #[derive(Deserialize)] diff --git a/crates/engine/tests/integration/unmaterialized_lki_serialization.rs b/crates/engine/tests/integration/unmaterialized_lki_serialization.rs index 8a2af55e55..c34ee79954 100644 --- a/crates/engine/tests/integration/unmaterialized_lki_serialization.rs +++ b/crates/engine/tests/integration/unmaterialized_lki_serialization.rs @@ -71,6 +71,22 @@ fn journal_zone_change_record_mut(wire: &mut serde_json::Value) -> &mut serde_js .expect("fixture journal retains a zone-change command") } +fn triggered_stack_event_record_mut(wire: &mut serde_json::Value) -> &mut serde_json::Value { + wire["stack"] + .as_array_mut() + .expect("stack serializes as an array") + .iter_mut() + .find_map(|entry| { + entry + .get_mut("kind")? + .get_mut("data")? + .get_mut("trigger_event")? + .get_mut("data")? + .get_mut("record") + }) + .expect("fixture places the dies trigger on the stack") +} + #[test] fn dies_lki_trigger_restoration_keeps_game_state_serializable() { let mut scenario = GameScenario::new(); @@ -289,3 +305,113 @@ fn legacy_zone_change_trigger_records_restore_before_client_serialization() { "both live zone-change event roots must survive restoration" ); } + +#[test] +fn legacy_ceased_token_trigger_payloads_restore_after_trigger_is_stacked() { + let mut scenario = GameScenario::new(); + scenario.at_phase(engine::types::phase::Phase::PreCombatMain); + let dying = scenario + .add_creature_from_oracle(P0, "Ephemeral Trigger Token", 1, 1, DIES_TRIGGER) + .id(); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&dying) + .expect("token source exists") + .is_token = true; + runner + .state_mut() + .objects + .get_mut(&dying) + .expect("token source exists") + .damage_marked = 99; + let mut events = Vec::new(); + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut events); + process_triggers(runner.state_mut(), &events); + assert!( + !runner.state().objects.contains_key(&dying), + "a token source ceases to exist after dying" + ); + assert!( + !runner.state().stack.is_empty(), + "the dies trigger is already an independent ability on the stack" + ); + + let mut wire = + serde_json::to_value(ResolutionStateWire::from_game_state(runner.state().clone())) + .expect("token fixture serializes as a resolution wire"); + let state = wire.as_object_mut().expect("wire is a state object"); + let mut legacy_record = state["zone_changes_this_turn"] + .as_array() + .expect("zone-change ledger serializes as an array") + .iter() + .find(|record| record["object_id"] == serde_json::Value::from(dying.0)) + .expect("token death remains in the current-turn ledger") + .clone(); + legacy_record + .as_object_mut() + .expect("ledger record is an object") + .remove("trigger_source_context"); + erase_trigger_occurrences(&mut legacy_record); + state["zone_changes_this_turn"] = serde_json::Value::Array(vec![legacy_record.clone()]); + state.insert( + "created_tokens_this_turn".to_string(), + serde_json::Value::Array(vec![legacy_record.clone()]), + ); + state.insert( + "sacrificed_permanents_this_turn".to_string(), + serde_json::Value::Array(vec![legacy_record]), + ); + let stacked_record = triggered_stack_event_record_mut(&mut wire); + stacked_record + .as_object_mut() + .expect("stacked trigger event record is an object") + .remove("trigger_source_context"); + erase_trigger_occurrences(stacked_record); + + let mut active_event = wire.clone(); + active_event["current_trigger_event"] = + active_event["stack"][0]["kind"]["data"]["trigger_event"].clone(); + let error = serde_json::from_value::(active_event) + .expect_err("an active legacy event must retain strict source provenance"); + assert!( + error + .to_string() + .contains("legacy zone-change record has no same-id persisted object base set"), + "only historical ledgers and stacked triggers may prune a ceased source payload" + ); + + let restored = serde_json::from_value::(wire) + .expect("ceased-source historical payloads no longer block reload") + .into_game_state(); + for records in [ + &restored.created_tokens_this_turn, + &restored.sacrificed_permanents_this_turn, + &restored.zone_changes_this_turn, + ] { + assert!( + records + .iter() + .all(|record| record.trigger_definitions.is_empty()), + "a ceased token source keeps no fabricated trigger occurrence" + ); + } + let stacked_event = restored + .stack + .iter() + .find_map(|entry| match &entry.kind { + engine::types::game_state::StackEntryKind::TriggeredAbility { + trigger_event: Some(GameEvent::ZoneChanged { record, .. }), + .. + } => Some(record), + _ => None, + }) + .expect("the already-stacked token trigger survives restoration"); + assert!( + stacked_event.trigger_definitions.is_empty(), + "the stacked trigger retains its ability, not an invented source occurrence" + ); + serde_json::to_value(ClientGameStateRef::wrap(&restored, Some(P0))) + .expect("the restored state remains serializable for the browser"); +} From 47d31066f3237018d3bf1ddafba4bb28089db2bf Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 23:08:26 -0700 Subject: [PATCH 2/3] Fix Clippy comparison in persistence regression --- .../tests/integration/unmaterialized_lki_serialization.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/unmaterialized_lki_serialization.rs b/crates/engine/tests/integration/unmaterialized_lki_serialization.rs index c34ee79954..4809fa3a3b 100644 --- a/crates/engine/tests/integration/unmaterialized_lki_serialization.rs +++ b/crates/engine/tests/integration/unmaterialized_lki_serialization.rs @@ -346,7 +346,7 @@ fn legacy_ceased_token_trigger_payloads_restore_after_trigger_is_stacked() { .as_array() .expect("zone-change ledger serializes as an array") .iter() - .find(|record| record["object_id"] == serde_json::Value::from(dying.0)) + .find(|record| record["object_id"] == dying.0) .expect("token death remains in the current-turn ledger") .clone(); legacy_record From a7c5f7eec21e6e20fbfb69994086b629b934d1ec Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 23:33:33 -0700 Subject: [PATCH 3/3] Preserve ceased trigger journals on restore --- crates/engine/src/types/game_state.rs | 47 +++++------- .../unmaterialized_lki_serialization.rs | 76 +++++++++++++++++-- 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index ef1f44e01b..5caa20ac40 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -8041,11 +8041,17 @@ pub(crate) fn migrate_legacy_zone_change_trigger_provenance( )?; if let Some(journal) = state.get_mut("resolved_rules_journal") { - visit_persisted_journal_zone_change_trigger_records( - journal, - objects, - &mut trigger_bases, - )?; + visit_persisted_journal_zone_change_trigger_records(journal, &mut |record| { + prune_ceased_source_legacy_trigger_payload(record, objects) + })?; + visit_persisted_journal_zone_change_trigger_records(journal, &mut |record| { + migrate_persisted_zone_change_trigger_record( + record, + objects, + &mut trigger_bases, + false, + ) + })?; } Ok(()) })() @@ -8054,9 +8060,9 @@ pub(crate) fn migrate_legacy_zone_change_trigger_provenance( migration } -/// CR 400.7 + CR 603.3: a ceased source cannot be reconstructed through a -/// same-id object lookup, while an already-stacked triggered ability keeps its -/// captured ability independently of that source. Historical ledgers and stack +/// CR 400.7 + CR 113.7a: a ceased source cannot be reconstructed through a +/// same-id object lookup, while an already-stacked triggered ability exists +/// independently of that source. Historical ledgers, journals, and stack /// entries may therefore discard only an all-payload legacy trigger list for a /// source which no longer exists. Live event carriers remain strict below: they /// can still need the exact trigger occurrence to finish trigger collection. @@ -8306,40 +8312,25 @@ fn migrate_persisted_zone_change_trigger_record( /// object's printed base set. fn visit_persisted_journal_zone_change_trigger_records( value: &mut serde_json::Value, - objects: &serde_json::Map, - trigger_bases: &mut HashMap, + 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_journal_zone_change_trigger_records(value, objects, trigger_bases)?; + visit_persisted_journal_zone_change_trigger_records(value, visit)?; } } serde_json::Value::Object(object) => { if let Some(record) = serialized_zone_changed_record_mut(object) { - migrate_persisted_zone_change_trigger_record( - record, - objects, - trigger_bases, - false, - )?; + visit(record)?; return Ok(()); } if let Some(record) = object.get_mut("zone_change_record") { - migrate_persisted_zone_change_trigger_record( - record, - objects, - trigger_bases, - false, - )?; + visit(record)?; } for (key, value) in object { if key != "zone_change_record" { - visit_persisted_journal_zone_change_trigger_records( - value, - objects, - trigger_bases, - )?; + visit_persisted_journal_zone_change_trigger_records(value, visit)?; } } } diff --git a/crates/engine/tests/integration/unmaterialized_lki_serialization.rs b/crates/engine/tests/integration/unmaterialized_lki_serialization.rs index 4809fa3a3b..ea8242ac7b 100644 --- a/crates/engine/tests/integration/unmaterialized_lki_serialization.rs +++ b/crates/engine/tests/integration/unmaterialized_lki_serialization.rs @@ -8,7 +8,7 @@ use engine::types::actions::GameAction; use engine::types::events::GameEvent; use engine::types::game_state::WaitingFor; use engine::types::resolution::ResolutionStateWire; -use engine::types::resolved_commands::ResolvedZoneChangeCommand; +use engine::types::resolved_commands::{ResolvedRulesCommand, ResolvedZoneChangeCommand}; const DIES_TRIGGER: &str = "When this creature dies, create a 1/1 green Squirrel creature token."; @@ -57,16 +57,20 @@ fn erase_trigger_occurrences(record: &mut serde_json::Value) { record["trigger_definitions"] = serde_json::Value::Array(entries); } -fn journal_zone_change_record_mut(wire: &mut serde_json::Value) -> &mut serde_json::Value { +fn journal_zone_change_record_mut( + wire: &mut serde_json::Value, + object_id: u64, +) -> &mut serde_json::Value { wire["resolved_rules_journal"]["entries"] .as_array_mut() .expect("journal entries serialize as an array") .iter_mut() .find_map(|entry| { - entry + let record = entry .get_mut("command")? .get_mut("ZoneChange")? - .get_mut("zone_change_record") + .get_mut("zone_change_record"); + record.filter(|record| record["object_id"] == object_id) }) .expect("fixture journal retains a zone-change command") } @@ -227,10 +231,10 @@ fn legacy_zone_change_trigger_records_restore_before_client_serialization() { ); erase_trigger_occurrences(&mut state["current_trigger_event"]["data"]["record"]); erase_trigger_occurrences(&mut state["pending_trigger_event_batch"][0]["data"]["record"]); - erase_trigger_occurrences(journal_zone_change_record_mut(&mut wire)); + erase_trigger_occurrences(journal_zone_change_record_mut(&mut wire, dying.0)); let mut context_free_journal = wire.clone(); - journal_zone_change_record_mut(&mut context_free_journal) + journal_zone_change_record_mut(&mut context_free_journal, dying.0) .as_object_mut() .expect("journal record is an object") .remove("trigger_source_context"); @@ -337,6 +341,40 @@ fn legacy_ceased_token_trigger_payloads_restore_after_trigger_is_stacked() { !runner.state().stack.is_empty(), "the dies trigger is already an independent ability on the stack" ); + let record = runner + .state() + .zone_changes_this_turn + .iter() + .find(|record| record.object_id == dying) + .expect("token death remains in the current-turn ledger") + .clone(); + let source = record + .trigger_source_context + .as_ref() + .expect("token death retains its exact source context") + .identity + .reference; + let cause = runner + .state_mut() + .resolved_rules_journal + .begin_proposal() + .expect("fixture opens a journal proposal"); + runner + .state_mut() + .resolved_rules_journal + .record_zone_change(ResolvedZoneChangeCommand { + object: source, + resulting_incarnation: source.incarnation + 1, + from: record.from_zone.expect("dies source left a zone"), + to: record.to_zone, + destination_position: 0, + owner: record.owner, + entry_timestamp: None, + turn_zone_change_index: record.turn_zone_change_index, + zone_change_record: record, + cause, + }) + .expect("fixture journals the token death"); let mut wire = serde_json::to_value(ResolutionStateWire::from_game_state(runner.state().clone())) @@ -369,6 +407,12 @@ fn legacy_ceased_token_trigger_payloads_restore_after_trigger_is_stacked() { .expect("stacked trigger event record is an object") .remove("trigger_source_context"); erase_trigger_occurrences(stacked_record); + let journal_record = journal_zone_change_record_mut(&mut wire, dying.0); + journal_record + .as_object_mut() + .expect("journal record is an object") + .remove("trigger_source_context"); + erase_trigger_occurrences(journal_record); let mut active_event = wire.clone(); active_event["current_trigger_event"] = @@ -412,6 +456,26 @@ fn legacy_ceased_token_trigger_payloads_restore_after_trigger_is_stacked() { stacked_event.trigger_definitions.is_empty(), "the stacked trigger retains its ability, not an invented source occurrence" ); + let journal_records = restored + .resolved_rules_journal + .entries() + .iter() + .filter_map(|entry| match entry.command.as_ref() { + Some(ResolvedRulesCommand::ZoneChange(command)) => Some(&command.zone_change_record), + _ => None, + }) + .filter(|record| record.object_id == dying) + .collect::>(); + assert!( + !journal_records.is_empty(), + "fixture retains the historical token-death journal record" + ); + assert!( + journal_records + .iter() + .all(|record| record.trigger_definitions.is_empty()), + "the historical journal keeps no fabricated token trigger occurrence" + ); serde_json::to_value(ClientGameStateRef::wrap(&restored, Some(P0))) .expect("the restored state remains serializable for the browser"); }