From c161e2c7ed43455dba097e1b044bb94acbc579b2 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 2 Aug 2026 11:53:43 -0700 Subject: [PATCH] fix: restore legacy game states and retarget controls --- client/src/components/modal/DialogHost.tsx | 7 +- .../modal/__tests__/DialogHost.test.tsx | 23 +++ crates/engine/src/types/game_state.rs | 168 ++++++++++++++++++ crates/engine/src/types/resolution.rs | 1 + .../game_state_boxed_ability_serde.rs | 72 ++++++++ 5 files changed, 270 insertions(+), 1 deletion(-) diff --git a/client/src/components/modal/DialogHost.tsx b/client/src/components/modal/DialogHost.tsx index 72bc8017f0..a1aea61c91 100644 --- a/client/src/components/modal/DialogHost.tsx +++ b/client/src/components/modal/DialogHost.tsx @@ -48,7 +48,6 @@ export const CLICK_THROUGH_WAITING_FOR_TYPES: ReadonlySet = "TriggerTargetSelection", "CopyTargetChoice", "CopyRetarget", - "RetargetChoice", "ExploreChoice", "PopulateChoice", "ReturnAsAuraTarget", @@ -62,6 +61,12 @@ export function isClickThroughWaitingFor( objects?: Record, ): boolean { if (!waitingFor) return false; + // CR 115.7: only a one-target retarget uses the board-picker. An `All` + // retarget renders RetargetChoiceModal, whose card choices and confirmation + // button need the host to retain pointer events. + if (waitingFor.type === "RetargetChoice") { + return waitingFor.data.scope.type === "Single"; + } if (CLICK_THROUGH_WAITING_FOR_TYPES.has(waitingFor.type)) return true; return getBoardChoiceView(waitingFor, objects) != null; } diff --git a/client/src/components/modal/__tests__/DialogHost.test.tsx b/client/src/components/modal/__tests__/DialogHost.test.tsx index 5853b20608..f7972c5a0e 100644 --- a/client/src/components/modal/__tests__/DialogHost.test.tsx +++ b/client/src/components/modal/__tests__/DialogHost.test.tsx @@ -248,6 +248,29 @@ describe("DialogHost", () => { expect(wrapper?.style.pointerEvents).toBe("none"); }); + it("keeps all-target retarget dialogs interactive", () => { + // CR 115.7: a single target is selected on the battlefield, but an `All` + // retarget uses RetargetChoiceModal. The host must not make that modal's + // target cards and Confirm button inherit `pointer-events: none`. + setWaitingFor({ + type: "RetargetChoice", + data: { + player: 0, + stack_entry_index: 0, + scope: { type: "All" }, + current_targets: [{ Object: 71 }], + legal_new_targets: [{ Object: 27 }, { Object: 43 }], + }, + }); + const { container } = render( + +
+ , + ); + const wrapper = container.firstElementChild as HTMLElement | null; + expect(wrapper?.style.pointerEvents).not.toBe("none"); + }); + it("resets peek to false when WaitingFor changes (regression)", () => { setWaitingFor({ type: "ModeChoice", data: { player: 0 } } as never); render( diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 4273bc2a39..f718d45b5b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -8332,6 +8332,174 @@ pub(crate) fn migrate_legacy_delayed_trigger_provenance( Ok(()) } +/// Upgrade `Effect::Mana.target` snapshots written before `ManaTargetRole` +/// made the target's grammatical role explicit. The old bare `TargetFilter` +/// cannot tell whether it named the mana recipient or the count source, so the +/// owning card's parsed Oracle clause is the migration authority. +/// +/// The lookup is intentionally name-based rather than shape-based: Carpet of +/// Flowers and Spectral Searchlight both serialize ordinary player filters but +/// assign opposite roles. Unknown cards fail restoration rather than silently +/// changing which player receives mana or supplies its amount. +pub(crate) fn migrate_legacy_mana_target_roles( + value: &mut serde_json::Value, +) -> Result<(), String> { + let object_names = value + .as_object() + .and_then(|state| state.get("objects")) + .and_then(serde_json::Value::as_object) + .map(|objects| { + objects + .iter() + .filter_map(|(key, object)| { + let object = object.as_object()?; + let id = object + .get("id") + .and_then(json_object_id) + .or_else(|| key.parse().ok())?; + let name = object.get("name")?.as_str()?; + (!name.is_empty()).then(|| (id, name.to_string())) + }) + .collect::>() + }) + .unwrap_or_default(); + + migrate_legacy_mana_target_roles_in_value(value, &object_names, None) +} + +fn json_object_id(value: &serde_json::Value) -> Option { + value + .as_u64() + .or_else(|| value.as_str()?.parse::().ok()) +} + +fn legacy_mana_target_role(card_name: &str) -> Option<(&'static str, &'static str)> { + match card_name { + // The named player receives the produced mana. This catalog is the + // complete pre-`ManaTargetRole` card-data surface; add future entries + // only after verifying their parsed Oracle role. + "A Display of My Dark Power" + | "Barbflare Gremlin" + | "Belbe, Corrupted Observer" + | "Bigger on the Inside" + | "Blighted Burgeoning" + | "Blinkmoth Urn" + | "Bubbling Muck" + | "Buried in the Garden" + | "Cheering Crowd" + | "Color Pie" + | "Dawn's Reflection" + | "Dictate of Karametra" + | "Eladamri, Lord of Leaves Avatar" + | "Eladamri's Vineyard" + | "Eloren Wilds" + | "Elvish Guidance" + | "Extraplanar Lens" + | "Fertile Ground" + | "Gauntlet of Might" + | "Gauntlet of Power" + | "Glittering Frost" + | "Heartbeat of Spring" + | "High Tide" + | "Jetfire, Ingenious Scientist" + | "Keeper of Progenitus" + | "Lavaleaper" + | "Mad Science Fair Project" + | "Magus of the Vineyard" + | "Mana Flare" + | "Market Festival" + | "Organ Harvest" + | "Overabundance" + | "Overgrowth" + | "Priest of Forgotten Gods" + | "Radiant Lotus" + | "Red Death, Shipwrecker" + | "Shimmerwilds Growth" + | "Shizuko, Caller of Autumn" + | "Snowfall" + | "Spectral Searchlight" + | "Stadium Vendors" + | "Tangleroot" + | "The Fertile Lands of Saulvinia" + | "The Warring Triad" + | "Trace of Abundance" + | "Utopia Sprawl" + | "Valleymaker" + | "Verdant Haven" + | "Vernal Bloom" + | "Wild Growth" + | "Winter's Night" + | "Wolfwillow Haven" + | "Zhur-Taa Ancient" => Some(("Recipient", "recipient")), + // The named player supplies the production count while the ability's + // controller receives the mana. Jeska's Will is the affected live-save + // case; Rousing Refrain has the same Oracle sentence. + "Carpet of Flowers" | "Jeska's Will" | "Orcish Squatters Avatar" | "Rousing Refrain" => { + Some(("CountSource", "count_source")) + } + _ => None, + } +} + +fn migrate_legacy_mana_target_roles_in_value( + value: &mut serde_json::Value, + object_names: &HashMap, + inherited_owner: Option, +) -> Result<(), String> { + match value { + serde_json::Value::Array(values) => { + for value in values { + migrate_legacy_mana_target_roles_in_value( + value, + object_names, + inherited_owner.clone(), + )?; + } + } + serde_json::Value::Object(object) => { + let source_owner = object + .get("source_id") + .and_then(json_object_id) + .and_then(|source_id| object_names.get(&source_id)) + .cloned(); + let owner = object + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or(source_owner) + .or(inherited_owner); + + let legacy_target = object + .get("type") + .and_then(serde_json::Value::as_str) + .is_some_and(|effect_type| effect_type == "Mana") + .then(|| object.get("target")) + .flatten() + .filter(|target| !target.is_null() && target.get("role").is_none()) + .cloned(); + if let Some(target) = legacy_target { + let owner = owner.as_deref().ok_or_else(|| { + "legacy mana target has no source card name for role migration".to_string() + })?; + let (role, field) = legacy_mana_target_role(owner).ok_or_else(|| { + format!("legacy mana target on {owner:?} has no verified role migration") + })?; + object.insert( + "target".to_string(), + serde_json::json!({ "role": role, field: target }), + ); + } + + for value in object.values_mut() { + migrate_legacy_mana_target_roles_in_value(value, object_names, owner.clone())?; + } + } + _ => {} + } + Ok(()) +} + fn delayed_trigger_install_command( entry: &serde_json::Value, ) -> Option<&serde_json::Map> { diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index 464472c3bd..f83f9534cd 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -2481,6 +2481,7 @@ impl ResolutionStateWire { // unlabeled active trigger must never be silently reclassified here. crate::types::game_state::migrate_legacy_delayed_trigger_provenance(&mut value)?; crate::types::game_state::migrate_legacy_trigger_firing_carriers(&mut value)?; + crate::types::game_state::migrate_legacy_mana_target_roles(&mut value)?; let object = value .as_object() .expect("the checked resolution state wire remains an object"); diff --git a/crates/engine/tests/integration/game_state_boxed_ability_serde.rs b/crates/engine/tests/integration/game_state_boxed_ability_serde.rs index c98849ec9f..2f3e497966 100644 --- a/crates/engine/tests/integration/game_state_boxed_ability_serde.rs +++ b/crates/engine/tests/integration/game_state_boxed_ability_serde.rs @@ -42,6 +42,7 @@ use engine::game::scenario::{P0, P1}; use engine::game::triggers::PendingTrigger; +use engine::game::zones::create_object; use engine::types::ability::{Effect, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef}; use engine::types::game_state::{ GameState, PendingCast, PendingDiscardForCostResume, PersistedGameState, StackEntry, @@ -49,6 +50,7 @@ use engine::types::game_state::{ }; use engine::types::identifiers::{CardId, ObjectId}; use engine::types::mana::ManaCost; +use engine::types::zones::Zone; const SOURCE: ObjectId = ObjectId(700); @@ -338,3 +340,73 @@ fn persisted_round_trip_preserves_the_boxed_resolving_stack_entry() { ResolvedAbility object on the persisted wire, got {persisted_ability}" ); } + +#[test] +fn persisted_restore_migrates_legacy_jeskas_will_mana_target_role() { + let mut state = state_with_resolving_stack_entry(); + let source = create_object( + &mut state, + CardId(2), + P0, + "Jeska's Will".to_string(), + Zone::Hand, + ); + let entry = state + .resolving_stack_entry + .as_mut() + .expect("reach-guard: resolving stack entry is populated"); + entry.source_id = source; + let ability = entry + .ability_mut() + .expect("reach-guard: resolving spell has an ability"); + ability.source_id = source; + + let mut persisted = serde_json::to_value(PersistedGameState::capture(state)) + .expect("a current persisted snapshot serializes"); + let effect = + &mut persisted["state"]["resolving_stack_entry"]["kind"]["data"]["ability"]["effect"]; + *effect = serde_json::json!({ + "type": "Mana", + "produced": { + "type": "AnyOneColor", + "count": { + "type": "Ref", + "qty": { "type": "TargetZoneCardCount", "zone": "Hand" } + }, + "color_options": ["Red"] + }, + "target": { + "type": "Typed", + "type_filters": [], + "controller": "Opponent", + "properties": [] + } + }); + + let target = + &persisted["state"]["resolving_stack_entry"]["kind"]["data"]["ability"]["effect"]["target"]; + assert!( + target.get("role").is_none(), + "reach-guard: the fixture must carry the pre-ManaTargetRole target encoding" + ); + + let restored = serde_json::from_value::(persisted) + .expect("legacy Jeska's Will snapshot restores through the persisted codec") + .into_game_state(); + let reserialized = serde_json::to_value(PersistedGameState::capture(restored)) + .expect("the migrated state reserializes"); + assert_eq!( + reserialized["state"]["resolving_stack_entry"]["kind"]["data"]["ability"]["effect"] + ["target"], + serde_json::json!({ + "role": "CountSource", + "count_source": { + "type": "Typed", + "type_filters": [], + "controller": "Opponent", + "properties": [] + } + }), + "Jeska's Will target must restore as its Oracle-defined count source" + ); +}