diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index d63c9a1905..79d1ba4794 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -1972,6 +1972,7 @@ fn legacy_duration(x: &Duration) -> bool { Duration::UntilEndOfTurn | Duration::UntilEndOfCombat | Duration::UntilHostLeavesPlay + | Duration::UntilSourceExilesAnotherCard | Duration::Permanent | Duration::UntilNextTurnOf { .. } | Duration::UntilEndOfNextTurnOf { .. } @@ -3643,6 +3644,7 @@ fn rw_duration(x: &Duration) -> RwProfile { Duration::UntilEndOfTurn | Duration::UntilEndOfCombat | Duration::UntilHostLeavesPlay + | Duration::UntilSourceExilesAnotherCard | Duration::Permanent => RwProfile::empty(), Duration::UntilNextTurnOf { player, .. } | Duration::UntilEndOfNextTurnOf { player, .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index c849529f27..b439436961 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2737,6 +2737,7 @@ fn scan_duration(x: &Duration) -> Axes { acc } Duration::UntilHostLeavesPlay => Axes::NONE, + Duration::UntilSourceExilesAnotherCard => Axes::NONE, Duration::UntilNextStepOf { player, .. } => { let mut acc = Axes::NONE; acc = acc.or(scan_player_scope(player)); diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index c0908bd1fd..209dd7183f 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1075,6 +1075,7 @@ fn fmt_duration(d: &Duration) -> String { format!("until end of next turn ({})", fmt_player_scope(player)) } Duration::UntilHostLeavesPlay => "while on battlefield".to_string(), + Duration::UntilSourceExilesAnotherCard => "until source exiles another card".to_string(), Duration::UntilNextStepOf { step, player } => { format!( "until next {} ({})", diff --git a/crates/engine/src/game/exile_links.rs b/crates/engine/src/game/exile_links.rs index ec65c372e7..a9bab62fd8 100644 --- a/crates/engine/src/game/exile_links.rs +++ b/crates/engine/src/game/exile_links.rs @@ -1,6 +1,6 @@ use serde::Serialize; -use crate::types::ability::ResolvedAbility; +use crate::types::ability::{CastingPermission, Duration, ResolvedAbility}; use crate::types::game_state::{ExileLink, ExileLinkKind, GameState}; use crate::types::identifiers::ObjectId; @@ -99,12 +99,46 @@ pub(crate) fn push_exiled_with_source_this_turn( exiled_id: ObjectId, source_id: ObjectId, ) { + let already_recorded = state + .cards_exiled_with_source_this_turn + .get(&source_id) + .is_some_and(|entry| entry.contains(&exiled_id)); + if already_recorded { + return; + } + + expire_until_source_exiles_another_card_durations(state, source_id); + let entry = state .cards_exiled_with_source_this_turn .entry(source_id) .or_default(); - if !entry.contains(&exiled_id) { - entry.push(exiled_id); + entry.push(exiled_id); +} + +// CR 611.2a + CR 607.2a: Source-linked durations expire when that same source +// exiles another card, whether stored as a play permission or a transient effect. +fn expire_until_source_exiles_another_card_durations(state: &mut GameState, source_id: ObjectId) { + for (_, object) in state.objects.iter_mut() { + object.casting_permissions.retain(|permission| { + !matches!( + permission, + CastingPermission::PlayFromExile { + duration: Duration::UntilSourceExilesAnotherCard, + source_id: Some(permission_source), + .. + } if *permission_source == source_id + ) + }); + } + + let before = state.transient_continuous_effects.len(); + state.transient_continuous_effects.retain(|effect| { + !(effect.duration == Duration::UntilSourceExilesAnotherCard + && effect.source_id == source_id) + }); + if state.transient_continuous_effects.len() != before { + state.layers_dirty.mark_full(); } } @@ -210,7 +244,8 @@ mod tests { }; use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; - use crate::types::zones::Zone; + use crate::types::statics::CastFrequency; + use crate::types::zones::{EtbTapState, Zone}; /// CR 702.167a/c: a `CraftMaterial` link must survive the craft source's /// battlefield exit (it self-exiles mid-activation and returns with the same @@ -267,6 +302,143 @@ mod tests { ); } + fn play_from_exile_permission(duration: Duration, source_id: ObjectId) -> CastingPermission { + CastingPermission::PlayFromExile { + duration, + granted_to: PlayerId(0), + frequency: CastFrequency::Unlimited, + source_id: Some(source_id), + exiled_by_ability_controller: None, + mana_spend_permission: None, + card_filter: None, + single_use_group: None, + single_use: false, + cast_cost_raise: None, + land_enter_tapped: EtbTapState::Unspecified, + } + } + + #[test] + fn source_exile_duration_expires_previous_permission_on_next_source_exile() { + use crate::game::zones::create_object; + use crate::types::game_state::GameState; + use crate::types::identifiers::CardId; + + let mut state = GameState::new_two_player(1); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let other_source = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Other Source".to_string(), + Zone::Battlefield, + ); + let first = create_object( + &mut state, + CardId(3), + PlayerId(0), + "First Exiled Card".to_string(), + Zone::Exile, + ); + let second = create_object( + &mut state, + CardId(4), + PlayerId(0), + "Second Exiled Card".to_string(), + Zone::Exile, + ); + let other_card = create_object( + &mut state, + CardId(5), + PlayerId(0), + "Other Exiled Card".to_string(), + Zone::Exile, + ); + + push_exiled_with_source_this_turn(&mut state, first, source); + state + .objects + .get_mut(&first) + .unwrap() + .casting_permissions + .extend([ + play_from_exile_permission(Duration::UntilSourceExilesAnotherCard, source), + play_from_exile_permission(Duration::Permanent, source), + ]); + state + .objects + .get_mut(&other_card) + .unwrap() + .casting_permissions + .push(play_from_exile_permission( + Duration::UntilSourceExilesAnotherCard, + other_source, + )); + state.add_transient_continuous_effect( + source, + PlayerId(0), + Duration::UntilSourceExilesAnotherCard, + TargetFilter::SelfRef, + vec![], + None, + ); + state.add_transient_continuous_effect( + other_source, + PlayerId(0), + Duration::UntilSourceExilesAnotherCard, + TargetFilter::SelfRef, + vec![], + None, + ); + + push_exiled_with_source_this_turn(&mut state, first, source); + assert_eq!( + state.objects[&first].casting_permissions.len(), + 2, + "duplicate source/exiled pair must not expire its own freshly granted permission" + ); + assert_eq!( + state.transient_continuous_effects.len(), + 2, + "duplicate source/exiled pair must not expire source-event durations" + ); + + push_exiled_with_source_this_turn(&mut state, second, source); + + let first_permissions = &state.objects[&first].casting_permissions; + assert_eq!(first_permissions.len(), 1); + assert!( + matches!( + first_permissions.as_slice(), + [CastingPermission::PlayFromExile { + duration: Duration::Permanent, + .. + }] + ), + "second source exile should prune only the source-exile duration grant, got {first_permissions:?}" + ); + assert_eq!( + state.objects[&other_card].casting_permissions.len(), + 1, + "same duration from a different source must survive" + ); + assert_eq!( + state.transient_continuous_effects.len(), + 1, + "source-event transient duration from a different source must survive" + ); + assert_eq!( + state.transient_continuous_effects[0].source_id, + other_source + ); + } + #[test] fn plain_exile_effect_has_no_linked_exile_consumer() { let ability = ResolvedAbility::new( diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 60bf6dd44e..c7a3527e77 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -181,7 +181,10 @@ pub fn prune_end_of_turn_casting_permissions(state: &mut GameState) { .. } => false, CastingPermission::PlayFromExile { - duration: Duration::UntilNextTurnOf { .. } | Duration::Permanent, + duration: + Duration::UntilNextTurnOf { .. } + | Duration::UntilSourceExilesAnotherCard + | Duration::Permanent, .. } => true, // CR 513.1: `UntilNextStepOf { step: End }` is expired by diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index e343a172cf..1e6353849a 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -34600,6 +34600,36 @@ fn play_exiled_card_this_turn_regression_unchanged() { assert_eq!(mana_spend_permission, None); } +/// CR 607.2a + CR 611.2a: Furious Rise / Superior Foes / Unstable Amulet +/// grant permission to play the just-exiled card only until the same source +/// exiles another card. +#[test] +fn play_from_exile_until_source_exiles_another_card_uses_source_exile_duration() { + for text in [ + "you may play that card until you exile another card with ~", + "you may play it until you exile another card with ~", + ] { + let e = parse_effect(text); + let Effect::GrantCastingPermission { + permission, target, .. + } = e + else { + panic!("expected GrantCastingPermission for {text:?}, got {e:?}"); + }; + let CastingPermission::PlayFromExile { duration, .. } = permission else { + panic!("expected PlayFromExile permission for {text:?}"); + }; + assert_eq!(duration, Duration::UntilSourceExilesAnotherCard); + assert_eq!( + target, + TargetFilter::TrackedSet { + id: TrackedSetId(0) + }, + "source-exile duration must still bind the grant to the tracked exile set" + ); + } +} + /// Discriminating: the "for as long as it remains exiled, and mana of any /// type..." form (Blightwing Bandit class) must keep dispatching to /// `try_parse_exile_play_grant_with_any_mana` (duration `Permanent`), NOT be diff --git a/crates/engine/src/parser/oracle_nom/duration.rs b/crates/engine/src/parser/oracle_nom/duration.rs index b47f63a900..0aad2590c1 100644 --- a/crates/engine/src/parser/oracle_nom/duration.rs +++ b/crates/engine/src/parser/oracle_nom/duration.rs @@ -3,9 +3,9 @@ //! **Single authority for the phrase→`Duration` grammar** (oracle-parser //! SKILL §7). Parses: "until end of turn", "until end of combat", "until the //! end of your/their next turn", "until your/their next turn", "until your -//! next end step", "until ~/this creature leaves the battlefield", "for the -//! rest of the game", "for as long as [condition]", "this turn", "this/that -//! combat". +//! next end step", "until ~/this creature leaves the battlefield", "until you +//! exile another card with ~", "for the rest of the game", "for as long as +//! [condition]", "this turn", "this/that combat". //! //! Positional wrappers (`strip_trailing_duration` / `strip_leading_duration` //! in `oracle_effect/lower.rs`, the clause shell, and the combat-grant @@ -69,6 +69,12 @@ fn parse_until_body(input: &str) -> OracleResult<'_, Duration> { tag(" leaves the battlefield"), ), ), + // CR 607.2a + CR 611.2a: source-linked impulse grants such as + // Furious Rise last until the same source exiles another card. + value( + Duration::UntilSourceExilesAnotherCard, + tag("you exile another card with ~"), + ), )) .parse(input) } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 2d2fd500ab..274a6829e9 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -2014,6 +2014,10 @@ pub enum Duration { ForAsLongAs { condition: StaticCondition, }, + /// CR 611.2a + CR 607.2a: Permission/effect lasts until the same linked + /// source exiles another card. Used by "you may play that card until you + /// exile another card with [this object]" source-linked exile grants. + UntilSourceExilesAnotherCard, Permanent, } @@ -2270,7 +2274,9 @@ pub enum CastingPermission { #[serde(default, skip_serializing_if = "CastFrequency::is_unlimited")] frequency: CastFrequency, /// Source object whose once-per-turn slot is consumed when - /// `frequency` is bounded. Filled by `grant_permission::resolve`. + /// `frequency` is bounded, and whose later source-linked exile expires + /// `Duration::UntilSourceExilesAnotherCard` grants. Filled by + /// `grant_permission::resolve`. #[serde(default, skip_serializing_if = "Option::is_none")] source_id: Option, /// Controller of the ability that exiled this card and attached this @@ -20006,6 +20012,7 @@ mod tests { player: PlayerScope::Controller, }, Duration::UntilHostLeavesPlay, + Duration::UntilSourceExilesAnotherCard, Duration::Permanent, ]; let json = serde_json::to_string(&durations).unwrap(); diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index a9cce6deed..cd66c38ae8 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -36,7 +36,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | 22 | Attacks-alone / while-saddled combat constraint dropped | 51 | oracle_trigger.rs scan_for_phase / attacks-trigger constraint parsing; add SourceAttackingAlone/MinCoAttackers + TriggerCondition::SourceIsSaddled | | 23 | Effect modeled with structurally wrong variant / ability class | 51 | add-engine-effect: select the correct Effect/ability variant for the clause class | | 24 | Variable X / where-X count unbound (sentinel or unresolved Variable) | 37 | oracle_cost.rs / oracle_quantity.rs — allow QuantityExpr in count fields and bind trailing 'where X is' clauses | -| 25 | Wrong / dropped effect duration | 32 | oracle_nom/duration.rs — add until-event / two-turn / permanent duration variants | +| 25 | Wrong / dropped effect duration | 29 | oracle_nom/duration.rs — add until-event / two-turn / permanent duration variants | | 26 | Delayed / future-phase trigger flattened to immediate effect | 20 | add-trigger: wrap future-phase effects in CreateDelayedTrigger | | 27 | Cross-target group / shared-quality constraint dropped | 20 | oracle_target.rs multi_target — add SameController/SameZone/DistinctNames/Parity constraints | | 28 | Trigger/activation timing or ordinal restriction dropped | 17 | oracle_casting.rs scan_timing_restrictions + trigger constraint parsing | @@ -5015,7 +5015,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top -### 25. Wrong / dropped effect duration (32 cards) +### 25. Wrong / dropped effect duration (29 cards) **Signature.** Effect duration is wrong (UntilEndOfTurn where permanent/until-event/two-turn needed, or a spurious expiry added), or a 'until ' delayed-return is dropped. @@ -5033,7 +5033,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Ferris Wheel - Firja's Retribution - Fraying Sanity -- Furious Rise - Glorious End - Golden Guardian - Jinx @@ -5051,9 +5050,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Palace Jailer - Peace Talks - Plant a Sapling -- Superior Foes of Spider-Man - Trickery Charm -- Unstable Amulet - War of the Last Alliance