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
115 changes: 112 additions & 3 deletions crates/engine/src/game/sba.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2124,8 +2124,8 @@ fn check_token_cease_to_exist(state: &mut GameState, any_performed: &mut bool) {
.objects
.iter()
.filter(|(_, obj)| {
zones::token_is_outside_battlefield_and_stack(obj)
|| zones::copy_of_card_outside_battlefield_and_stack(obj)
zones::token_is_outside_battlefield_and_stack(state, obj)
|| zones::copy_of_card_outside_battlefield_and_stack(state, obj)
})
.map(|(id, obj)| (*id, obj.zone, obj.owner))
.collect();
Expand Down Expand Up @@ -2302,10 +2302,12 @@ mod tests {
use super::*;
use crate::game::zones::create_object;
use crate::types::ability::{
AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, TargetFilter,
AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ResolvedAbility,
TargetFilter,
};
use crate::types::actions::GameAction;
use crate::types::format::FormatConfig;
use crate::types::game_state::{CastingVariant, StackEntry, StackEntryKind};
use crate::types::identifiers::{CardId, ObjectId};
use crate::types::replacements::ReplacementEvent;

Expand Down Expand Up @@ -4909,6 +4911,113 @@ mod tests {
);
}

#[test]
fn announced_off_zone_noncard_survival_requires_same_id_spell_entry() {
fn add_off_zone_noncard(
state: &mut GameState,
card_id: u64,
name: &str,
is_token: bool,
is_copy: bool,
) -> ObjectId {
let id = create_object(
state,
CardId(card_id),
PlayerId(0),
name.to_string(),
Zone::Exile,
);
let object = state.objects.get_mut(&id).unwrap();
object.is_token = is_token;
object.is_copy = is_copy;
id
}

fn push_spell_placeholder(state: &mut GameState, id: ObjectId, card_id: u64) {
state.stack.push_back(StackEntry {
id,
source_id: id,
controller: PlayerId(0),
kind: StackEntryKind::Spell {
card_id: CardId(card_id),
ability: None,
casting_variant: CastingVariant::Normal,
actual_mana_spent: 0,
},
});
}

fn push_virtual_activated_entry(state: &mut GameState, id: ObjectId) {
state.stack.push_back(StackEntry {
id,
source_id: id,
controller: PlayerId(0),
kind: StackEntryKind::ActivatedAbility {
source_id: id,
ability: Box::new(ResolvedAbility::new(Effect::NoOp, vec![], id, PlayerId(0))),
},
});
}

let mut state = setup();
let announced_token = add_off_zone_noncard(&mut state, 1, "Announced Token", true, false);
let activated_token = add_off_zone_noncard(&mut state, 2, "Activated Token", true, false);
let unmatched_token = add_off_zone_noncard(&mut state, 3, "Unmatched Token", true, false);
let announced_copy = add_off_zone_noncard(&mut state, 4, "Announced Copy", false, true);
let activated_copy = add_off_zone_noncard(&mut state, 5, "Activated Copy", false, true);
let unmatched_copy = add_off_zone_noncard(&mut state, 6, "Unmatched Copy", false, true);

push_spell_placeholder(&mut state, announced_token, 1);
push_virtual_activated_entry(&mut state, activated_token);
push_spell_placeholder(&mut state, announced_copy, 4);
push_virtual_activated_entry(&mut state, activated_copy);

for id in [
announced_token,
activated_token,
unmatched_token,
announced_copy,
activated_copy,
unmatched_copy,
] {
assert!(state.objects.contains_key(&id));
assert_eq!(state.objects[&id].zone, Zone::Exile);
}
assert!(state.stack.iter().any(|entry| {
entry.id == activated_token
&& entry.source_id == activated_token
&& matches!(entry.kind, StackEntryKind::ActivatedAbility { .. })
}));
assert!(state.stack.iter().any(|entry| {
entry.id == activated_copy
&& entry.source_id == activated_copy
&& matches!(entry.kind, StackEntryKind::ActivatedAbility { .. })
}));

let mut events = Vec::new();
check_state_based_actions(&mut state, &mut events);

// CR 601.2a: The exact same-id spell placeholders make these announced
// spell objects stack-resident despite the retained Exile field.
assert!(state.objects.contains_key(&announced_token));
assert!(state.objects.contains_key(&announced_copy));

// CR 704.5d + CR 704.5e: Unmatched off-zone tokens/copies cease, and
// CR 109.1 / CR 602.2a means a same-id activated ability cannot protect
// its source.
for id in [
activated_token,
unmatched_token,
activated_copy,
unmatched_copy,
] {
assert!(
!state.objects.contains_key(&id),
"off-zone noncard object {id:?} must cease without its own spell entry"
);
}
}

// --- CR 704.5e + CR 707.10a: Copy-of-a-card cease-to-exist tests ---

/// A copy of a card (is_copy = true, is_token = false) resolving to the
Expand Down
63 changes: 61 additions & 2 deletions crates/engine/src/game/zone_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,8 +738,11 @@ pub(crate) fn move_object_with_terminal(
.get(&req.object_id)
.expect("object exists (zone read above)");
// CR 111.8: A token that has left the battlefield can't change zones; it
// remains in place and ceases to exist at the next SBA (CR 111.7).
if zones::token_is_outside_battlefield_and_stack(obj) {
// remains in place and ceases to exist at the next SBA (CR 111.7). A
// same-id CR 601.2a `StackEntryKind::Spell` placeholder makes an
// announced spell effectively stack-resident and eligible for its
// retained-origin representation's delivery to `Zone::Stack`.
if zones::token_is_outside_battlefield_and_stack(state, obj) {
return ZoneMoveTerminalResult::Completed(ZoneMoveCompletion::Remained);
}
// CR 603.2g + CR 603.6a: A Battlefield -> Battlefield move does not put a
Expand Down Expand Up @@ -3081,6 +3084,62 @@ fn execute_zone_move_with_applied_terminal(
}
}

#[cfg(test)]
mod announced_spell_residency_tests {
use super::*;
use crate::game::zones::create_object;
use crate::types::ability::{Effect, ResolvedAbility};
use crate::types::game_state::{StackEntry, StackEntryKind};
use crate::types::identifiers::CardId;

#[test]
fn casting_to_stack_rejects_same_id_activated_ability_entry() {
let mut state = GameState::new_two_player(42);
let object_id = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Activated Source".to_string(),
Zone::Exile,
);
state.objects.get_mut(&object_id).unwrap().is_token = true;
state.stack.push_back(StackEntry {
id: object_id,
source_id: object_id,
controller: PlayerId(0),
kind: StackEntryKind::ActivatedAbility {
source_id: object_id,
ability: Box::new(ResolvedAbility::new(
Effect::NoOp,
vec![],
object_id,
PlayerId(0),
)),
},
});
assert_eq!(state.objects[&object_id].zone, Zone::Exile);
assert!(state.stack.iter().any(|entry| {
entry.id == object_id && matches!(entry.kind, StackEntryKind::ActivatedAbility { .. })
}));

// CR 109.1 / CR 602.2a: A same-id activated ability is a distinct
// noncard stack object, so it cannot satisfy the spell-residency gate.
let mut events = Vec::new();
let result = move_object_with_terminal(
&mut state,
ZoneMoveRequest::casting_to_stack(object_id, object_id),
&mut events,
);

assert!(matches!(
result,
ZoneMoveTerminalResult::Completed(ZoneMoveCompletion::Remained)
));
assert_eq!(state.objects[&object_id].zone, Zone::Exile);
assert!(events.is_empty());
}
}

#[cfg(test)]
mod w3_library_placement_tests {
use super::*;
Expand Down
118 changes: 105 additions & 13 deletions crates/engine/src/game/zones.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::types::card_type::CoreType;
use crate::types::events::GameEvent;
use crate::types::game_state::{
GameState, ResolutionSourceRelatch, StackEntry, ZoneChangeCombatStatus,
GameState, ResolutionSourceRelatch, StackEntry, StackEntryKind, ZoneChangeCombatStatus,
};
use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef};
use crate::types::player::PlayerId;
Expand All @@ -17,11 +17,27 @@ use crate::types::zones::Zone;
use super::game_object::GameObject;
use super::printed_cards::{apply_back_face_to_object, snapshot_object_face};

/// CR 111.7 / CR 111.8: A token outside the battlefield ceases to exist at
/// the next SBA, and can't change zones before then. Stack tokens are excluded
/// so spell copies can finish resolving before the next SBA check.
pub(super) fn token_is_outside_battlefield_and_stack(obj: &GameObject) -> bool {
obj.is_token && obj.zone != Zone::Battlefield && obj.zone != Zone::Stack
/// CR 109.1 + CR 601.2a + CR 405.1: A spell is an object on the stack from
/// announcement, even while this engine retains its origin-zone field until
/// finalization. CR 602.2a / CR 603.3: Activated and triggered abilities are
/// distinct noncard stack objects, so a same-id non-spell entry cannot make its
/// source object stack-resident.
fn object_has_stack_residency(state: &GameState, obj: &GameObject) -> bool {
obj.zone == Zone::Stack
|| state.stack.iter().any(|entry| match &entry.kind {
StackEntryKind::Spell { .. } => entry.id == obj.id,
StackEntryKind::ActivatedAbility { .. }
| StackEntryKind::TriggeredAbility { .. }
| StackEntryKind::KeywordAction { .. } => false,
})
}

/// CR 704.5d / CR 111.7 / CR 111.8: A token outside the battlefield ceases to
/// exist at the next SBA and can't change zones before then. Effectively
/// stack-resident tokens are excluded so announced spell copies can finish
/// casting and resolving before the next applicable SBA check.
pub(super) fn token_is_outside_battlefield_and_stack(state: &GameState, obj: &GameObject) -> bool {
obj.is_token && obj.zone != Zone::Battlefield && !object_has_stack_residency(state, obj)
}

/// CR 704.5e + CR 707.10a: A copy of a card in any zone other than the stack or
Expand All @@ -30,8 +46,11 @@ pub(super) fn token_is_outside_battlefield_and_stack(obj: &GameObject) -> bool {
/// (CR 707.10f makes a permanent copy a token there) and may change zones freely
/// while alive, so this predicate is used ONLY by the cease-to-exist SBA — never
/// by the CR 111.8 "can't change zones" movement guards, which apply to tokens only.
pub(super) fn copy_of_card_outside_battlefield_and_stack(obj: &GameObject) -> bool {
obj.is_copy && obj.zone != Zone::Battlefield && obj.zone != Zone::Stack
pub(super) fn copy_of_card_outside_battlefield_and_stack(
state: &GameState,
obj: &GameObject,
) -> bool {
obj.is_copy && obj.zone != Zone::Battlefield && !object_has_stack_residency(state, obj)
}

/// CR 122.2 + CR 113.6b: Determine whether `object_id`'s counters survive a move
Expand Down Expand Up @@ -941,7 +960,7 @@ pub fn move_to_zone(
if state
.objects
.get(&object_id)
.is_some_and(token_is_outside_battlefield_and_stack)
.is_some_and(|obj| token_is_outside_battlefield_and_stack(state, obj))
{
return;
}
Expand Down Expand Up @@ -1638,7 +1657,7 @@ pub fn move_to_library_at_index(
if state
.objects
.get(&object_id)
.is_some_and(token_is_outside_battlefield_and_stack)
.is_some_and(|obj| token_is_outside_battlefield_and_stack(state, obj))
{
return;
}
Expand Down Expand Up @@ -2286,10 +2305,10 @@ mod tests {

use super::*;
use crate::types::ability::{
ContinuousModification, ControllerRef, FilterProp, StaticDefinition, TargetFilter,
TypeFilter, TypedFilter,
ContinuousModification, ControllerRef, Effect, FilterProp, ResolvedAbility,
StaticDefinition, TargetFilter, TypeFilter, TypedFilter,
};
use crate::types::game_state::GameState;
use crate::types::game_state::{CastingVariant, GameState};
use crate::types::keywords::Keyword;
use crate::types::mana::ManaCost;

Expand Down Expand Up @@ -2674,6 +2693,79 @@ mod tests {
);
}

#[test]
fn announced_token_move_requires_same_id_spell_entry() {
let mut state = setup();
let announced_spell = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Announced Spell Copy".to_string(),
Zone::Exile,
);
state.objects.get_mut(&announced_spell).unwrap().is_token = true;
state.stack.push_back(StackEntry {
id: announced_spell,
source_id: announced_spell,
controller: PlayerId(0),
kind: StackEntryKind::Spell {
card_id: CardId(1),
ability: None,
casting_variant: CastingVariant::Normal,
actual_mana_spent: 0,
},
});

let same_id_ability = create_object(
&mut state,
CardId(2),
PlayerId(0),
"Activated Source".to_string(),
Zone::Exile,
);
state.objects.get_mut(&same_id_ability).unwrap().is_token = true;
state.stack.push_back(StackEntry {
id: same_id_ability,
source_id: same_id_ability,
controller: PlayerId(0),
kind: StackEntryKind::ActivatedAbility {
source_id: same_id_ability,
ability: Box::new(ResolvedAbility::new(
Effect::NoOp,
vec![],
same_id_ability,
PlayerId(0),
)),
},
});

assert_eq!(state.objects[&announced_spell].zone, Zone::Exile);
assert_eq!(state.objects[&same_id_ability].zone, Zone::Exile);

let mut spell_events = Vec::new();
move_to_zone(&mut state, announced_spell, Zone::Stack, &mut spell_events);
assert_eq!(state.objects[&announced_spell].zone, Zone::Stack);
assert!(spell_events.iter().any(|event| {
matches!(
event,
GameEvent::ZoneChanged { object_id, to: Zone::Stack, .. }
if *object_id == announced_spell
)
}));

// CR 109.1 / CR 602.2a: The same-id activated ability is its own
// noncard stack object and cannot authorize movement of its source.
let mut ability_events = Vec::new();
move_to_zone(
&mut state,
same_id_ability,
Zone::Stack,
&mut ability_events,
);
assert_eq!(state.objects[&same_id_ability].zone, Zone::Exile);
assert!(ability_events.is_empty());
}

#[test]
fn create_object_increments_id() {
let mut state = setup();
Expand Down
Loading
Loading