-
-
Notifications
You must be signed in to change notification settings - Fork 154
fix(engine): release trigger carrier on drop and unstick empty attackers #7051
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1265,3 +1265,107 @@ fn modal_mode_tracking_resets_on_new_turn() { | |
| // Game-scoped should persist. | ||
| assert!(state.modal_modes_chosen_this_game.contains(&(source_id, 0))); | ||
| } | ||
|
|
||
| /// CR 603.3d: "If a choice is required when the triggered ability goes on the | ||
| /// stack but no legal choices can be made for it ... the ability is simply | ||
| /// removed from the stack." Removing it must release EVERY in-flight | ||
| /// construction cursor, including `pending_trigger_event_batch`. | ||
| /// | ||
| /// Regression: Nimble Obstructionist ("When you cycle this card, counter target | ||
| /// activated or triggered ability you don't control") cycled with nothing legal | ||
| /// to counter took this drop path. The drop cleared `pending_trigger` and | ||
| /// `pending_trigger_firing` but leaked the batch, latching a dead `Cycled` | ||
| /// event into the game state permanently — it then poisoned the trigger event | ||
| /// context of every later trigger that paused for a choice (firing "whenever a | ||
| /// player cycles" / "whenever you draw" observers for a cycle that never | ||
| /// happened) and permanently failed the settled-state gate that lets contiguous | ||
| /// inert trigger runs skip priority. | ||
| #[test] | ||
| fn no_legal_target_trigger_drop_releases_pending_trigger_event_batch() { | ||
| let mut state = GameState::new_two_player(42); | ||
| state.turn_number = 2; | ||
| state.phase = Phase::PreCombatMain; | ||
| state.active_player = PlayerId(0); | ||
| state.priority_player = PlayerId(0); | ||
|
|
||
| let source_id = create_object( | ||
| &mut state, | ||
| CardId(20), | ||
| PlayerId(0), | ||
| "Cycled Trigger Source".to_string(), | ||
| Zone::Graveyard, | ||
| ); | ||
|
|
||
| // The battlefield is deliberately empty, so a creature-targeting trigger has | ||
| // no legal target at choose-time — the CR 603.3d removal branch. | ||
| let ability = ResolvedAbility::new( | ||
| Effect::DealDamage { | ||
| amount: QuantityExpr::Fixed { value: 1 }, | ||
| target: TargetFilter::Typed(TypedFilter::creature()), | ||
| damage_source: None, | ||
| excess: None, | ||
| }, | ||
| Vec::new(), | ||
| source_id, | ||
| PlayerId(0), | ||
| ); | ||
|
|
||
| let cycled_event = GameEvent::Cycled { | ||
| player_id: PlayerId(0), | ||
| object_id: source_id, | ||
| }; | ||
| let pending = crate::game::triggers::PendingTrigger { | ||
| source_id, | ||
| controller: PlayerId(0), | ||
| condition: None, | ||
| ability: Box::new(ability), | ||
| timestamp: 1, | ||
| target_constraints: Vec::new(), | ||
| distribute: None, | ||
| trigger_event: Some(cycled_event.clone()), | ||
| modal: None, | ||
| mode_abilities: vec![], | ||
| description: Some("When you cycle this card, counter target ability".to_string()), | ||
| may_trigger_origin: None, | ||
| subject_match_count: None, | ||
| die_result: None, | ||
| provenance: None, | ||
| }; | ||
| let pending_for_state = pending.clone(); | ||
| let mut setup_events = Vec::new(); | ||
| let entry_id = crate::game::triggers::push_pending_trigger_to_stack( | ||
| &mut state, | ||
| pending, | ||
| &mut setup_events, | ||
| ); | ||
| state.pending_trigger = Some(Box::new(pending_for_state)); | ||
| mark_ordinary_pending_trigger_construction(&mut state, entry_id); | ||
| // Production installs the carrier AFTER the push (the push drains it), which | ||
| // is exactly the state a paused construction is re-entered in. | ||
| state.pending_trigger_event_batch = vec![cycled_event]; | ||
|
|
||
| // Non-vacuity guard: the assertion below is only meaningful if the carrier is | ||
| // actually populated going in. `push_pending_trigger_to_stack` DRAINS the | ||
| // batch, so a future reordering of this fixture would silently turn the | ||
| // regression assert into a tautology that passes with the fix reverted. | ||
| assert!( | ||
| !state.pending_trigger_event_batch.is_empty(), | ||
| "fixture must enter the drop path with a populated carrier" | ||
| ); | ||
|
|
||
| let waiting = crate::game::engine::begin_pending_trigger_target_selection(&mut state) | ||
| .expect("no-legal-target drop is not an engine error"); | ||
|
|
||
| assert!( | ||
| waiting.is_none(), | ||
| "CR 603.3d: a trigger with no legal target must not surface a prompt" | ||
| ); | ||
| assert!( | ||
| state.pending_trigger_event_batch.is_empty(), | ||
| "CR 603.3d: removing the ability must release its event-batch carrier, \ | ||
| not latch a dead event into the game state" | ||
| ); | ||
| assert!(state.pending_trigger.is_none()); | ||
| assert!(state.pending_trigger_entry.is_none()); | ||
| assert!(state.pending_trigger_firing.is_none()); | ||
|
Comment on lines
+1368
to
+1370
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Assert that the illegal stack entry is removed. Line 1368 only verifies that the pending cursor is cleared. Add assertions that 🤖 Prompt for AI AgentsSource: Path instructions |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Correct the CR citation.
Line 11526 cites CR 608.2c for a path that removes an ability while it is being put on the stack. CR 608.2c governs following written instructions during resolution. Keep CR 603.3c and CR 603.3d, and remove CR 608.2c from this annotation. (blogs.magicjudges.org)
Based on learnings: “Cite CR 608.2c only when the comment is documenting the resolution of written instructions ‘in order’.”
🤖 Prompt for AI Agents
Sources: Path instructions, Learnings