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
10 changes: 8 additions & 2 deletions crates/engine/src/game/effects/prepare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,14 @@ pub(crate) fn open_copy_target_selection(

fn cleanup_failed_prepared_copy_cast(state: &mut GameState, copy_id: ObjectId) {
// Defensive cleanup for any failed cast attempt after synthesizing the
// ephemeral copy object.
state.stack.retain(|entry| entry.id != copy_id);
// ephemeral copy object. The predicate filters on a unique id, so this
// removes at most ONE entry — routed through the shared stack-removal
// authority rather than expressed as a `retain`, which would leave both
// per-entry side tables stranded and the removal unjournaled.
if let Some(idx) = state.stack.iter().position(|entry| entry.id == copy_id) {
crate::game::stack::remove_stack_entry_at(state, idx)
.expect("position yielded a live stack index");
}
state.objects.remove(&copy_id);
}

Expand Down
16 changes: 14 additions & 2 deletions crates/engine/src/game/elimination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,8 +605,20 @@ fn do_eliminate(

crate::game::planechase::preserve_phenomenon_stack_abilities_for_handoff(state, planar_handoff);

// CR 800.4a: Remove spells they control from the stack
state.stack.retain(|entry| entry.controller != player);
// CR 800.4a: Remove spells they control from the stack, one at a time
// through the shared stack-removal authority — the same shape as the
// scheduled-control release below. A `retain` would drop several entries in
// one unjournalable mutation; removing by position instead records each
// entry with the index it occupied at the moment IT was removed, so a replay
// reproduces both the count and the surviving entries' relative order.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
while let Some(idx) = state
.stack
.iter()
.position(|entry| entry.controller == player)
{
super::stack::remove_stack_entry_at(state, idx)
.expect("position yielded a live stack index");
}

// CR 800.4a + CR 800.4b: A control-another-player effect (CR 723, e.g.
// Mindslaver / Secret of Bloodbending) ends when EITHER party leaves the
Expand Down
11 changes: 9 additions & 2 deletions crates/engine/src/game/zones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1703,8 +1703,15 @@ pub fn remove_from_zone(state: &mut GameState, object_id: ObjectId, zone: Zone,
}
Zone::Battlefield => state.battlefield.retain(|id| *id != object_id),
Zone::Stack => {
state.stack.retain(|e| e.id != object_id);
state.stack_paid_facts.remove(&object_id);
// A unique id, so at most ONE entry matches. Routed through the
// shared stack-removal authority, which journals it and drops BOTH
// per-entry side tables (this arm previously dropped only
// `stack_paid_facts`). A miss is normal: the resolution pop already
// removed the entry before the card is routed to its next zone.
if let Some(idx) = state.stack.iter().position(|e| e.id == object_id) {
crate::game::stack::remove_stack_entry_at(state, idx)
.expect("position yielded a live stack index");
}
}
Zone::Exile => state.exile.retain(|id| *id != object_id),
Zone::Command => {
Expand Down
102 changes: 102 additions & 0 deletions crates/engine/tests/integration/cr733_resolved_stack_removal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,105 @@ fn a_recorded_pop_unblocks_a_later_push_replay() {
"CR 405.2: the replay installs the recorded entry at the recorded index"
);
}

/// CR 800.4a: a leaving player's spells are removed from the stack. This is the
/// PLURAL case — one `retain` pass used to drop several entries at once — and it
/// is journaled as one command per entry rather than one bulk record.
///
/// The load-bearing assertion is that BOTH removals record index 0. An
/// implementation that captured each entry's ORIGINAL position would record
/// (0, 1); recording (0, 0) is what proves each index is the LIVE index at the
/// moment that entry was removed, which is the only form a sequential replay can
/// reproduce.
///
/// The leaving player is the one casting, so no priority juggling is needed: a
/// self-targeted burn spell takes its own controller to 0 and the CR 704.5a
/// state-based action runs the sweep through the real pipeline.
#[test]
fn eliminating_a_player_journals_one_removal_per_controlled_stack_entry() {
// Three players so the elimination does not end the game outright.
let mut scenario = GameScenario::new_n_player(3, 7);
scenario.at_phase(Phase::PreCombatMain);
let bolt = scenario.add_bolt_to_hand(P0);
let victim_lower = scenario
.add_spell_to_hand_from_oracle(P0, "Journal Victim Lower", true, ELVISH_TOKEN_SPELL)
.with_mana_cost(ManaCost::zero())
.id();
let victim_upper = scenario
.add_spell_to_hand_from_oracle(P0, "Journal Victim Upper", true, ELVISH_TOKEN_SPELL)
.with_mana_cost(ManaCost::zero())
.id();
let mut runner = scenario.build();

runner
.state_mut()
.players
.iter_mut()
.find(|player| player.id == P0)
.expect("the caster is in the game")
.life = 3;

cast(&mut runner, victim_lower);
cast(&mut runner, victim_upper);
assert_eq!(
runner
.state()
.stack
.iter()
.map(|entry| entry.id)
.collect::<Vec<_>>(),
vec![victim_lower, victim_upper],
"reach guard: both of the leaving player's spells are on the stack"
);
let journal_start = runner.state().resolved_rules_journal.entries().len();

// CR 704.5a through the real pipeline: self-targeted burn takes the caster
// to 0 and the state-based action eliminates them, running the CR 800.4a
// sweep over the two spells still on the stack beneath it.
runner.cast(bolt).target_player(P0).resolve();

assert!(
runner
.state()
.players
.iter()
.find(|player| player.id == P0)
.expect("the player record survives elimination")
.is_eliminated,
"CR 704.5a reach guard: the caster actually left the game"
);

let victim_removals: Vec<_> = stack_removals(runner.state(), journal_start)
.into_iter()
.filter(|removal| removal.entry.id == victim_lower || removal.entry.id == victim_upper)
.collect();
assert_eq!(
victim_removals.len(),
2,
"CR 800.4a: each removed entry is journaled separately, not as one bulk record"
);
assert_eq!(
(victim_removals[0].entry.id, victim_removals[1].entry.id),
(victim_lower, victim_upper),
"the sweep removes by ascending position, and the records carry that order"
);
assert_eq!(
(victim_removals[0].index, victim_removals[1].index),
(0, 0),
"each index is the LIVE index at its own removal: once the lower spell \
leaves index 0 the upper one slides down into it. Original-position \
capture would record (0, 1) and a replay would remove the wrong entry"
);
assert_eq!(
(
victim_removals[0].resulting_depth,
victim_removals[1].resulting_depth
),
(1, 0),
"the depths descend one per removal"
);
assert!(
runner.state().stack.is_empty(),
"CR 800.4a: every spell the leaving player controlled is gone"
Comment on lines +461 to +545

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Exercise selective removal and both side-table cleanup paths.

This stack contains only P0 entries, so an implementation that also removes P1’s entries still passes the filtered journal assertions and final is_empty() check. It also never proves cleanup of stack_paid_facts or stack_trigger_event_batches. Add a P1-controlled stack entry that survives, and create reachable rows for both P0 side tables with reach guards before asserting their removal; update the expected resulting depths accordingly.

As per path instructions, tests must exercise the failure path and include positive reach guards.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/cr733_resolved_stack_removal.rs` around lines
461 - 545, Extend
eliminating_a_player_journals_one_removal_per_controlled_stack_entry to add a
P1-controlled stack entry and assert it remains after P0 elimination, proving
removal is selective. Before triggering the failure path, create reachable P0
rows in stack_paid_facts and stack_trigger_event_batches with positive reach
guards, then assert both are cleaned up. Update the expected removal depths and
final stack assertions to account for the surviving P1 entry while retaining
per-entry journal-order checks.

Source: Path instructions

);
}
Loading