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
6,174 changes: 149 additions & 6,025 deletions crates/engine/data/known-tokens.toml

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions crates/engine/data/oracle-subtypes.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"Blinkmoth",
"Boar",
"Book",
"Borg",
"Boxer",
"Brainiac",
"Bringer",
Expand Down Expand Up @@ -184,6 +185,7 @@
"Killbot",
"Kirin",
"Kithkin",
"Klingon",
"Knight",
"Koala",
"Kobold",
Expand Down Expand Up @@ -273,6 +275,7 @@
"Prism",
"Processor",
"Proper",
"Q",
"Qu",
"Rabbit",
"Raccoon",
Expand Down Expand Up @@ -350,6 +353,7 @@
"Thrull",
"Tiefling",
"Time Lord",
"Tosk",
"Townsfolk",
"Toy",
"Treasure",
Expand All @@ -370,6 +374,7 @@
"Vehicle",
"Villain",
"Volver",
"Vulcan",
"Waiter",
"Wall",
"Walrus",
Expand Down
8 changes: 4 additions & 4 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11307,10 +11307,10 @@ mod tests {
fn analyze_token_coverage_treats_source_defined_pt_as_represented() {
let summary = analyze_token_coverage();

assert_eq!(summary.total_tokens, 2844);
assert_eq!(summary.supported_tokens, 2844);
assert_eq!(summary.rules_text_tokens, 1479);
assert_eq!(summary.parsed_rules_text_tokens, 1479);
assert_eq!(summary.total_tokens, 2845);
assert_eq!(summary.supported_tokens, 2845);
assert_eq!(summary.rules_text_tokens, 1480);
assert_eq!(summary.parsed_rules_text_tokens, 1480);
assert_eq!(summary.total_tokens - summary.supported_tokens, 0);
assert!(!summary.top_gaps.iter().any(|gap| {
gap.handler == TOKEN_BODY_DYNAMIC_OR_SOURCE_DEFINED_POWER_TOUGHNESS_LABEL
Expand Down
182 changes: 166 additions & 16 deletions crates/engine/src/game/effects/delayed_trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,10 +704,14 @@ fn snapshot_quantity_ref(
/// Three responsibilities:
/// 1. Resolve TrackedSetId(0) sentinel → TrackedSetId(real_id)
/// 2. Bind TargetFilter::Any → TrackedSet(real_id) for implicit pronouns
/// 3. Set origin zone to Exile (tracked sets are always from exile)
/// 3. Preserve the parsed `origin` (Battlefield for token cleanup, Exile for
/// cross-clause exiled-card references). When unset, `change_zone::resolve_all`
/// derives scan zones from tracked-set members at firing time.
fn bind_tracked_set_to_effect(effect: &mut Effect, real_id: TrackedSetId) {
match effect {
Effect::ChangeZoneAll { origin, target, .. } => {
Effect::ChangeZoneAll {
origin: _, target, ..
} => {
// Resolve target filter
match target {
TargetFilter::TrackedSet {
Expand All @@ -718,10 +722,6 @@ fn bind_tracked_set_to_effect(effect: &mut Effect, real_id: TrackedSetId) {
}
_ => {}
}
// CR 400.7: Tracked objects are in exile; set origin for zone scan
if origin.is_none() {
*origin = Some(Zone::Exile);
}
}
// CR 603.7c + CR 608.2c: Pin the tracked-set sentinel `TrackedSetId(0)` to
// the concrete `real_id` inside the mass-destroy target filter at
Expand All @@ -736,15 +736,32 @@ fn bind_tracked_set_to_effect(effect: &mut Effect, real_id: TrackedSetId) {
Effect::DestroyAll { target, .. } => target.rebind_tracked_set_sentinel(real_id),
// Upgrade ChangeZone → ChangeZoneAll: ChangeZone uses ability.targets (empty for
// delayed triggers), so it would move nothing. ChangeZoneAll scans by filter.
Effect::ChangeZone { destination, .. } => {
Effect::ChangeZone {
destination,
origin,
target,
enters_under,
enter_tapped,
enter_with_counters,
face_down_profile,
..
} => {
let bound_target = match target {
TargetFilter::TrackedSet {
id: TrackedSetId(0),
}
| TargetFilter::Any => TargetFilter::TrackedSet { id: real_id },
TargetFilter::TrackedSet { id } => TargetFilter::TrackedSet { id: *id },
_ => TargetFilter::TrackedSet { id: real_id },
};
*effect = Effect::ChangeZoneAll {
origin: Some(Zone::Exile),
origin: *origin,
destination: *destination,
target: TargetFilter::TrackedSet { id: real_id },
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enter_with_counters: vec![],
face_down_profile: None,
target: bound_target,
enters_under: enters_under.clone(),
enter_tapped: *enter_tapped,
enter_with_counters: enter_with_counters.clone(),
face_down_profile: face_down_profile.clone(),
library_position: None,
random_order: false,
};
Expand Down Expand Up @@ -1391,7 +1408,7 @@ mod tests {
.expect("sub-ability chain must be preserved");
match &sub.effect {
Effect::ChangeZoneAll { origin, target, .. } => {
assert_eq!(*origin, Some(Zone::Exile));
assert_eq!(*origin, None);
assert_eq!(
*target,
TargetFilter::TrackedSet {
Expand Down Expand Up @@ -1447,15 +1464,16 @@ mod tests {
let result = resolve(&mut state, &ability, &mut events);
assert!(result.is_ok());

// Should be upgraded to ChangeZoneAll with resolved TrackedSetId and Exile origin
// Should be upgraded to ChangeZoneAll with resolved TrackedSetId; origin
// stays unset so runtime derives member zones when firing.
match &state.delayed_triggers[0].ability.effect {
Effect::ChangeZoneAll {
origin,
destination,
target,
..
} => {
assert_eq!(*origin, Some(Zone::Exile));
assert_eq!(*origin, None);
assert_eq!(*destination, Zone::Battlefield);
assert_eq!(
*target,
Expand Down Expand Up @@ -2641,4 +2659,136 @@ mod tests {
"the later, unrelated token must survive — the snapshot did not drift to it"
);
}

/// CR 603.7c + CR 608.2c (issue #5972): plural "those tokens" delayed exile
/// binds the tracked set with origin `Battlefield`. A token that already
/// left the battlefield is skipped; the remaining member is exiled.
#[test]
fn tracked_set_battlefield_cleanup_skips_departed_token() {
let mut state = GameState::new_two_player(42);
let first_token = crate::game::zones::create_object(
&mut state,
CardId(1),
PlayerId(0),
"Twinflame Token A".to_string(),
Zone::Battlefield,
);
let second_token = crate::game::zones::create_object(
&mut state,
CardId(2),
PlayerId(0),
"Twinflame Token B".to_string(),
Zone::Battlefield,
);
for token in [first_token, second_token] {
state.objects.get_mut(&token).unwrap().card_types.core_types =
vec![crate::types::card_type::CoreType::Creature];
}

state
.tracked_object_sets
.insert(TrackedSetId(1), vec![first_token, second_token]);
state.next_tracked_set_id = 2;
state.chain_tracked_set_id = Some(TrackedSetId(1));

// One token leaves the battlefield before end-step cleanup fires.
crate::game::zones::move_to_zone(&mut state, first_token, Zone::Graveyard, &mut Vec::new());

let delayed = ResolvedAbility::new(
Effect::ChangeZoneAll {
origin: Some(Zone::Battlefield),
destination: Zone::Exile,
target: TargetFilter::TrackedSet {
id: TrackedSetId(1),
},
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enter_with_counters: vec![],
face_down_profile: None,
library_position: None,
random_order: false,
},
vec![],
ObjectId(100),
PlayerId(0),
);

let mut events = Vec::new();
crate::game::effects::resolve_ability_chain(&mut state, &delayed, &mut events, 0)
.expect("tracked-set battlefield cleanup resolves");

assert_eq!(
state.objects[&first_token].zone,
Zone::Graveyard,
"a token that already left the battlefield must not be exiled by cleanup"
);
assert_eq!(
state.objects[&second_token].zone,
Zone::Exile,
"the remaining tracked-set token on the battlefield must be exiled"
);
}

/// CR 603.7c (issue #5972): binding preserves an explicit Battlefield
/// origin when upgrading `ChangeZone { TrackedSet }` → `ChangeZoneAll`.
#[test]
fn bind_tracked_set_preserves_battlefield_origin_on_change_zone_upgrade() {
let mut state = GameState::new_two_player(42);
state
.tracked_object_sets
.insert(TrackedSetId(1), vec![ObjectId(10)]);
state.next_tracked_set_id = 2;

let effect_def = AbilityDefinition::new(
AbilityKind::Spell,
Effect::ChangeZone {
origin: Some(Zone::Battlefield),
destination: Zone::Exile,
target: TargetFilter::TrackedSet {
id: TrackedSetId(0),
},
owner_library: false,
enter_transformed: false,
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enters_attacking: false,
up_to: false,
enter_with_counters: vec![],
conditional_enter_with_counters: vec![],
face_down_profile: None,
enters_modified_if: None,
},
);
let ability = ResolvedAbility::new(
Effect::CreateDelayedTrigger {
condition: DelayedTriggerCondition::AtNextPhase { phase: Phase::End },
effect: Box::new(effect_def),
uses_tracked_set: true,
},
vec![],
ObjectId(5),
PlayerId(0),
);
let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).expect("resolve must succeed");

match &state.delayed_triggers[0].ability.effect {
Effect::ChangeZoneAll {
origin,
destination,
target,
..
} => {
assert_eq!(*origin, Some(Zone::Battlefield));
assert_eq!(*destination, Zone::Exile);
assert_eq!(
*target,
TargetFilter::TrackedSet {
id: TrackedSetId(1)
}
);
}
other => panic!("expected ChangeZoneAll, got {other:?}"),
}
}
}
23 changes: 17 additions & 6 deletions crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3032,16 +3032,27 @@ pub(super) fn rewrite_parent_target_to_last_created(
Effect::ChangeZone { target, origin, .. }
if matches!(
target,
TargetFilter::ParentTarget
| TargetFilter::TriggeringSource
| TargetFilter::TrackedSet { .. }
TargetFilter::ParentTarget | TargetFilter::TriggeringSource
) =>
{
// CR 603.7c: In the gated post-token scope, both singular
// anaphors and plural "those tokens" refer to the token(s) just
// created and must still be in the battlefield zone at cleanup.
// CR 603.7c: In the gated post-token scope, singular "it"/"that
// token" anaphors refer to the one just-created token and must
// still be on the battlefield at cleanup — bind `LastCreated`.
// Plural "those tokens" is already rewritten to `TrackedSet` by
// `rewrite_parent_targets_to_tracked_set` (Saheeli -7, Twinflame);
// do not stomp it here — `LastCreated` only snapshots the last
// token in a multi-token batch (issue #5972).
rewrite_change_zone_cleanup_to_last_created(target, origin);
}
Effect::ChangeZone {
target: TargetFilter::TrackedSet { .. },
origin,
..
} => {
// CR 603.7c: plural token cleanup stays on `TrackedSet`; stamp the
// battlefield as the expected origin at firing time (issue #5972).
origin.get_or_insert(Zone::Battlefield);
}
_ => {}
}
}
Expand Down
48 changes: 48 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38803,6 +38803,54 @@ fn delayed_trigger_change_zone_after_token_creator_rewrites_to_last_created() {
);
}

/// CR 603.7 + CR 707.2 (issue #5972): Saheeli, the Gifted's -7 creates a
/// token copy of each artifact, grants haste to "those tokens", then exiles
/// "those tokens" at the next end step. The plural anaphor must stay on
/// `TrackedSet` with `uses_tracked_set: true` — not `LastCreated`, which
/// only binds the last token in the batch.
#[test]
fn saheeli_minus_seven_those_tokens_delayed_exile_keeps_tracked_set() {
use crate::types::identifiers::TrackedSetId;

let ability = parse_effect_chain(
"for each artifact you control, create a token that's a copy of it. those tokens gain haste. exile those tokens at the beginning of the next end step",
AbilityKind::Activated,
);

fn find_delayed_exile(def: &AbilityDefinition) -> Option<(bool, TargetFilter)> {
if let Effect::CreateDelayedTrigger {
uses_tracked_set,
effect: inner,
..
} = &*def.effect
{
if let Effect::ChangeZone {
target,
destination: Zone::Exile,
..
} = &*inner.effect
{
return Some((*uses_tracked_set, target.clone()));
}
}
def.sub_ability.as_deref().and_then(find_delayed_exile)
}

let (uses_tracked_set, target) =
find_delayed_exile(&ability).expect("expected delayed exile ChangeZone in chain");
assert!(
uses_tracked_set,
"those tokens delayed cleanup must set uses_tracked_set=true"
);
assert_eq!(
target,
TargetFilter::TrackedSet {
id: TrackedSetId(0)
},
"plural token cleanup must bind TrackedSet, not LastCreated"
);
}

/// CR 614.1a + CR 614.6: Whip-style "If it would leave the battlefield,
/// exile it instead of putting it anywhere else" is a replacement effect
/// installed on the returned object, not an immediate follow-up exile.
Expand Down
Loading
Loading