Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion client/src/components/modal/DialogHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export const CLICK_THROUGH_WAITING_FOR_TYPES: ReadonlySet<WaitingFor["type"]> =
"TriggerTargetSelection",
"CopyTargetChoice",
"CopyRetarget",
"RetargetChoice",
"ExploreChoice",
"PopulateChoice",
"ReturnAsAuraTarget",
Expand All @@ -62,6 +61,12 @@ export function isClickThroughWaitingFor(
objects?: Record<ObjectId, GameObject | undefined>,
): 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;
}
Expand Down
23 changes: 23 additions & 0 deletions client/src/components/modal/__tests__/DialogHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DialogHost>
<div data-testid="retarget-modal" />
</DialogHost>,
);
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(
Expand Down
168 changes: 168 additions & 0 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<HashMap<_, _>>()
})
.unwrap_or_default();

migrate_legacy_mana_target_roles_in_value(value, &object_names, None)
}

fn json_object_id(value: &serde_json::Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_str()?.parse::<u64>().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<u64, String>,
inherited_owner: Option<String>,
) -> 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<String, serde_json::Value>> {
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/types/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@

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,
StackEntryKind, WaitingFor,
};
use engine::types::identifiers::{CardId, ObjectId};
use engine::types::mana::ManaCost;
use engine::types::zones::Zone;

const SOURCE: ObjectId = ObjectId(700);

Expand Down Expand Up @@ -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::<PersistedGameState>(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"
);
}
Loading