Skip to content
Open
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
2 changes: 2 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1972,6 +1972,7 @@ fn legacy_duration(x: &Duration) -> bool {
Duration::UntilEndOfTurn
| Duration::UntilEndOfCombat
| Duration::UntilHostLeavesPlay
| Duration::UntilSourceExilesAnotherCard
| Duration::Permanent
| Duration::UntilNextTurnOf { .. }
| Duration::UntilEndOfNextTurnOf { .. }
Expand Down Expand Up @@ -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, .. }
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {} ({})",
Expand Down
180 changes: 176 additions & 4 deletions crates/engine/src/game/exile_links.rs
Original file line number Diff line number Diff line change
@@ -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;

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions crates/engine/src/parser/oracle_nom/duration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
9 changes: 8 additions & 1 deletion crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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<ObjectId>,
/// Controller of the ability that exiled this card and attached this
Expand Down Expand Up @@ -20006,6 +20012,7 @@ mod tests {
player: PlayerScope::Controller,
},
Duration::UntilHostLeavesPlay,
Duration::UntilSourceExilesAnotherCard,
Duration::Permanent,
];
let json = serde_json::to_string(&durations).unwrap();
Expand Down
7 changes: 2 additions & 5 deletions docs/parser-misparse-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -5015,7 +5015,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top

</details>

### 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 <state change>' delayed-return is dropped.

Expand All @@ -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
Expand All @@ -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

</details>
Expand Down
Loading