diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 414d1df19a..aefcc6ee5f 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3313,6 +3313,34 @@ fn split_player_scope_chain( (scoped, tail) } +/// CR 608.2c: A multi-target player subject owns only its same-sentence +/// continuation chain. A `SequentialSibling` starts an independent instruction +/// that resolves once after every selected player has completed the subject's +/// local clauses. This is the target-subject counterpart to +/// `split_player_scope_chain` above. +fn split_multi_target_player_chain( + ability: &ResolvedAbility, +) -> (ResolvedAbility, Option>) { + let mut per_target = ability.clone(); + let tail = detach_after_multi_target_player_local_chain(&mut per_target); + (per_target, tail) +} + +/// CR 608.2c: Retain `ContinuationStep` clauses under the current "each target +/// player" subject, but detach the first independently sequenced instruction. +fn detach_after_multi_target_player_local_chain( + node: &mut ResolvedAbility, +) -> Option> { + let mut next = node.sub_ability.take()?; + if next.sub_link != SubAbilityLink::ContinuationStep { + return Some(next); + } + + let tail = detach_after_multi_target_player_local_chain(&mut next); + node.sub_ability = Some(next); + tail +} + /// CR 608.2e: Collect cross-player equalization quantity references from a /// `QuantityExpr`. These are the refs whose value would shift as an APNAP /// fan-out mutates the board — `ControlledByEachPlayer` (battlefield extremum) @@ -3552,6 +3580,23 @@ fn effect_target_filter(effect: &Effect) -> Option<&TargetFilter> { effect.target_filter() } +/// CR 601.2c: Oracle's `target opponents` lowering can use the dedicated +/// `TargetFilter::Opponent` player role or a type-less `TypedFilter` constrained +/// solely by `ControllerRef::Opponent`; `target players` uses +/// `TargetFilter::Player`. All three select players at announcement and need +/// the same per-target resolution fan-out. +fn is_multi_target_player_filter(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Player | TargetFilter::Opponent => true, + TargetFilter::Typed(typed) => { + typed.type_filters.is_empty() + && typed.properties.is_empty() + && matches!(typed.controller, Some(ControllerRef::Opponent)) + } + _ => false, + } +} + // ── Batch resolution (Tier 3) ──────────────────────────────────────────── // // Driver-level collapse of N contiguous, identical, observer-free @@ -7562,11 +7607,12 @@ fn resolve_chain_body( return Ok(()); } - // CR 603.3d + CR 601.2c: Multi-target-over-`Player` resolution fan-out. - // An "any number of target players each " trigger/spell announces a - // variable number of player targets when it goes on the stack (CR 601.2c, - // reached for triggers via CR 603.3d). The chosen player set lands in - // `ability.targets` as `TargetRef::Player` entries with `multi_target` set. + // CR 603.3d + CR 601.2c: Multi-target player-subject resolution fan-out. + // An "any number of target players/opponents each " trigger/spell + // announces a variable number of player targets when it goes on the stack + // (CR 601.2c, reached for triggers via CR 603.3d). The chosen player set + // lands in `ability.targets` as `TargetRef::Player` entries with + // `multi_target` set. // Single-player-recipient effect handlers (`Discard`, `Mill`, `LoseLife`) // resolve for only the FIRST `TargetRef::Player`, so without this branch a // selection of two players discards only one. This fan-out is the missing @@ -7580,7 +7626,7 @@ fn resolve_chain_body( // `multi_target` and changes the effect's target filter, which would // otherwise skip this branch and resolve only the first chosen player. if ability.multi_target.is_some() - && effect_target_filter(&ability.effect) == Some(&TargetFilter::Player) + && effect_target_filter(&ability.effect).is_some_and(is_multi_target_player_filter) { let chosen_players: Vec = ability .targets @@ -7591,6 +7637,11 @@ fn resolve_chain_body( }) .collect(); if chosen_players.len() != 1 { + // A sentence boundary begins an instruction that is not qualified + // by the preceding "each target player" subject. Keep that tail + // outside the per-target template so Wheel and Deal's final + // "Draw a card" is performed once by its controller. + let (per_target, after_fanout) = split_multi_target_player_chain(ability); // CR 601.2c: "any number of target players" permits zero targets. // CR 608.2c: An ability resolving with zero chosen player targets // does nothing — emit `EffectResolved` and stop BEFORE @@ -7605,6 +7656,9 @@ fn resolve_chain_body( source_id: ability.source_id, subject: None, }); + if let Some(after_fanout) = after_fanout { + resolve_ability_chain(state, &after_fanout, events, depth + 1)?; + } return Ok(()); } // CR 101.4 + CR 608.2c: Resolve the effect once per chosen player in @@ -7617,7 +7671,7 @@ fn resolve_chain_body( .collect(); let initial_waiting_for = state.waiting_for.clone(); for (i, pid) in fanout_players.iter().enumerate() { - let mut narrowed = ability.clone(); + let mut narrowed = per_target.clone(); narrowed.targets = vec![TargetRef::Player(*pid)]; narrowed.multi_target = None; resolve_ability_chain(state, &narrowed, events, depth + 1)?; @@ -7629,9 +7683,9 @@ fn resolve_chain_body( // `drain_pending_continuation` after the choice resolves. if state.waiting_for != initial_waiting_for { let remaining = &fanout_players[i + 1..]; - let mut tail: Option> = None; + let mut tail = after_fanout.clone(); for &remaining_pid in remaining.iter().rev() { - let mut remaining_narrowed = ability.clone(); + let mut remaining_narrowed = per_target.clone(); remaining_narrowed.targets = vec![TargetRef::Player(remaining_pid)]; remaining_narrowed.multi_target = None; if let Some(prev) = tail { @@ -7646,6 +7700,11 @@ fn resolve_chain_body( break; } } + if state.waiting_for == initial_waiting_for { + if let Some(after_fanout) = after_fanout { + resolve_ability_chain(state, &after_fanout, events, depth + 1)?; + } + } return Ok(()); } } @@ -23543,6 +23602,92 @@ mod tests { ); } + /// CR 601.2c + CR 608.2c: If a multi-target opponent instruction pauses, + /// every remaining opponent must resume before its detached sequential tail + /// runs once for the controller. + #[test] + fn multi_target_opponents_resume_before_controller_sequential_tail() { + let mut state = GameState::new(FormatConfig::commander(), 3, 42); + for card in 0..3 { + create_object( + &mut state, + CardId(1_000 + card), + PlayerId(0), + format!("Controller library {card}"), + Zone::Library, + ); + } + for player in 1..3u8 { + for card in 0..2 { + create_object( + &mut state, + CardId(u64::from(player) * 100 + card), + PlayerId(player), + format!("P{player} hand {card}"), + Zone::Hand, + ); + } + } + + let opponent_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![], + controller: Some(ControllerRef::Opponent), + properties: vec![], + }); + let mut ability = ResolvedAbility::new( + Effect::Discard { + count: QuantityExpr::Fixed { value: 1 }, + target: opponent_filter, + selection: crate::types::ability::CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + }, + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)), + ], + ObjectId(500), + PlayerId(0), + ); + ability.multi_target = Some(crate::types::ability::MultiTargetSpec::unlimited(0)); + let mut controller_tail = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(500), + PlayerId(0), + ); + controller_tail.sub_link = crate::types::ability::SubAbilityLink::SequentialSibling; + ability.sub_ability = Some(Box::new(controller_tail)); + + let controller_hand_before = state.players[0].hand.len(); + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + let mut prompted_players = Vec::new(); + while let WaitingFor::DiscardChoice { player, cards, .. } = &state.waiting_for { + let player = *player; + let pick = cards[0]; + crate::game::engine::apply_as_current( + &mut state, + GameAction::SelectCards { cards: vec![pick] }, + ) + .unwrap(); + prompted_players.push(player); + } + + assert_eq!(prompted_players, vec![PlayerId(1), PlayerId(2)]); + assert_eq!(state.players[1].graveyard.len(), 1); + assert_eq!(state.players[2].graveyard.len(), 1); + assert_eq!( + state.players[0].hand.len() - controller_hand_before, + 1, + "the detached controller draw runs exactly once after both discards" + ); + } + /// GH #582 — CR 104.2b + CR 107.3i + CR 608.2c + CR 700.5: Thassa's Oracle /// runtime discriminator. The synthesized AST gates `Effect::WinTheGame` /// with `AbilityCondition::QuantityCheck { lhs: Devotion{[Blue]}, comparator: GE, rhs: diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 0cd300b29a..65d099c963 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -2155,12 +2155,14 @@ impl<'a> SpellCast<'a> { &mut events, )?; - // Intent the driver matches as it walks slots: object targets are - // consumed one per slot (most slots are object slots), while player - // targets are reusable across slots (one player may be targeted by - // several modes — see `pick_slot_target`). + // Intent the driver matches as it walks slots. Object targets are + // always consumed one per slot. Player declarations are consumed only + // by a multi-target run so `.target_players(&[a, b])` can express two + // distinct targets while a single declaration remains reusable across + // independent modal slots. let mut remaining_objects: Vec = target_objects; let declared_players: Vec = target_players; + let mut remaining_multi_target_players = declared_players.clone(); let mut remaining_cost_objects: Vec = cost_objects; // CR 601.2a: the spell leaves hand only at stack commit. Captured when @@ -2358,6 +2360,7 @@ impl<'a> SpellCast<'a> { } // CR 601.2c: declare one target per slot, in written order. WaitingFor::TargetSelection { + pending_cast, target_slots, selection, .. @@ -2366,6 +2369,11 @@ impl<'a> SpellCast<'a> { let choice = pick_slot_target( slot, &mut remaining_objects, + pending_cast + .ability + .multi_target + .as_ref() + .map(|_| &mut remaining_multi_target_players), &declared_players, selection.current_slot, ); @@ -2547,15 +2555,17 @@ impl<'a> CastCommit<'a> { /// matching CR 601.2c (targets declared one per slot, in written order). /// /// Object intent is *consumed* (each declared object satisfies at most one -/// slot, so distinct exile/destroy targets never alias). Player intent is -/// *reusable* — the same player is routinely targeted by several modes of one -/// modal spell (e.g. Kozilek's Command mode 1 scries *and* draws for the same -/// target player), so a declared player may satisfy multiple player slots. +/// slot, so distinct exile/destroy targets never alias). A multi-target run +/// consumes player declarations in order when available; otherwise, player +/// intent is reusable, letting the same declared player satisfy independent +/// modal slots (e.g. Kozilek's Command mode 1 scries *and* draws for the same +/// target player). /// Falls back to `None` for optional slots; panics for an unsatisfiable /// required slot. fn pick_slot_target( slot: &crate::types::game_state::TargetSelectionSlot, remaining_objects: &mut Vec, + remaining_multi_target_players: Option<&mut Vec>, declared_players: &[PlayerId], slot_index: usize, ) -> Option { @@ -2565,6 +2575,14 @@ fn pick_slot_target( { return Some(TargetRef::Object(remaining_objects.remove(pos))); } + if let Some(remaining_players) = remaining_multi_target_players { + if let Some(pos) = remaining_players + .iter() + .position(|&player| slot.legal_targets.contains(&TargetRef::Player(player))) + { + return Some(TargetRef::Player(remaining_players.remove(pos))); + } + } if let Some(&player) = declared_players .iter() .find(|&&p| slot.legal_targets.contains(&TargetRef::Player(p))) @@ -2882,6 +2900,7 @@ impl<'a> AbilityActivation<'a> { let choice = pick_slot_target( slot, &mut remaining_objects, + None, &declared_players, selection.current_slot, ); @@ -3106,6 +3125,7 @@ fn drive_resolution( let choice = pick_slot_target( slot, &mut remaining_objects, + None, declared_players, selection.current_slot, ); @@ -3127,6 +3147,7 @@ fn drive_resolution( let choice = pick_slot_target( slot, &mut remaining_objects, + None, declared_players, selection.current_slot, ); diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 904f2a96ea..368f8a6334 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -9,11 +9,20 @@ //! The clause-lowering helpers this traversal calls still live in `lower.rs` //! (widened to `pub(super)` for this move); relocating them is a later increment. +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::character::complete::multispace0; +use nom::combinator::value; +use nom::sequence::preceded; +use nom::Parser; + use crate::parser::oracle_ir::ast::*; use crate::parser::oracle_ir::effect_chain::{ AbsorbKind, ClauseDisposition, ClauseId, EffectChainIr, OtherwiseKind, PlayerScopeRewrite, PriorModifier, ReplaceMeaningKind, ReplicateKind, }; +use crate::parser::oracle_nom::bridge::nom_on_lower; +use crate::parser::oracle_nom::error::OracleError; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, CastFromZoneDriver, CastingPermission, ControllerRef, Effect, PlayerFilter, QuantityExpr, StaticCondition, @@ -70,6 +79,55 @@ use super::{ wire_optional_cast_decline_fallback, }; +/// CR 601.2c: True when the assembled head chose one or more players at +/// announcement, including Oracle's type-less `target opponents` lowering. +fn is_multi_target_player_subject_definition(def: &AbilityDefinition) -> bool { + def.multi_target.is_some() + && def + .effect + .target_filter() + .is_some_and(|target| match target { + TargetFilter::Player | TargetFilter::Opponent => true, + TargetFilter::Typed(typed) => { + typed.type_filters.is_empty() + && typed.properties.is_empty() + && matches!(typed.controller, Some(ControllerRef::Opponent)) + } + _ => false, + }) +} + +/// CR 608.2c: A bare verb after a multi-target player subject continues that +/// subject. A printed player subject (especially `you`) starts an independent +/// actor-relative instruction instead. +fn has_implicit_player_subject_continuation(source: &str) -> bool { + let lower = source.to_ascii_lowercase(); + nom_on_lower(source, &lower, |input| { + value( + (), + preceded( + multispace0, + alt(( + tag::<_, _, OracleError<'_>>("you "), + tag("target "), + tag("each "), + tag("that "), + tag("those "), + tag("the "), + tag("a player "), + tag("an opponent "), + tag("players "), + tag("opponents "), + tag("it "), + tag("they "), + )), + ), + ) + .parse(input) + }) + .is_none() +} + // =========================================================================== // AssemblyEnv (Plan 01 §6, U6-B1) — emit-time provenance + role registries // =========================================================================== @@ -2015,6 +2073,32 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { def = def.multi_target(spec.clone()); } } + // CR 601.2c + CR 608.2c: A conjugated continuation after an "any + // number of target players/opponents each" head shares the targets + // selected for that head. At this assembly seam, the previous definition + // has its finalized `multi_target` shape and this clause still has its + // source text, so bind a bare draw ("then draw seven cards") to + // `ParentTarget`. An explicit imperative ("then you draw a card") + // remains controller-relative. + if def.sub_link == SubAbilityLink::ContinuationStep + && defs + .last() + .is_some_and(is_multi_target_player_subject_definition) + && has_implicit_player_subject_continuation( + clause_ir.source.fragment().unwrap_or_default(), + ) + { + if let Effect::Draw { target, .. } = def.effect.as_mut() { + if matches!( + target, + TargetFilter::Controller + | TargetFilter::Player + | TargetFilter::ParentTargetController + ) { + *target = TargetFilter::ParentTarget; + } + } + } if parse_controlled_by_different_players_target_constraint( clause_ir.source.fragment().unwrap_or_default(), ) { diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 7edf095edd..1fc0c9ba08 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -19640,6 +19640,7 @@ fn target_filter_can_target_player(filter: &TargetFilter) -> bool { match filter { TargetFilter::Player | TargetFilter::Controller + | TargetFilter::Opponent | TargetFilter::ScopedPlayer | TargetFilter::TriggeringSpellController | TargetFilter::TriggeringSpellOwner diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 581c9a6c63..49ecede5bb 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -11643,6 +11643,72 @@ fn effect_chain_then_conjugated_draws() { ); } +/// CR 601.2c + CR 608.2c: a bare continuation after an "any number of target +/// opponents each" subject inherits the chosen-player target set. The following +/// sentence remains a controller-only instruction. +#[test] +fn wheel_and_deal_implicit_draw_inherits_each_targeted_opponent() { + let def = parse_effect_chain( + "Any number of target opponents each discard their hands, then draw seven cards.\nDraw a card.", + AbilityKind::Spell, + ); + assert!( + def.multi_target.is_some() + && def + .effect + .target_filter() + .is_some_and(target_filter_can_target_player), + "the first sentence must remain a multi-target player instruction: {def:?}" + ); + let per_opponent_draw = def + .sub_ability + .as_ref() + .expect("discard must continue to the seven-card draw"); + assert!( + matches!( + &*per_opponent_draw.effect, + Effect::Draw { + count: QuantityExpr::Fixed { value: 7 }, + target: TargetFilter::ParentTarget, + } + ), + "expected the seven-card draw to inherit ParentTarget, got {:?}; full definition: {def:?}", + per_opponent_draw.effect + ); + let controller_draw = per_opponent_draw + .sub_ability + .as_ref() + .expect("the second sentence must remain a trailing draw"); + assert!(matches!( + &*controller_draw.effect, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } + )); +} + +/// CR 608.2c: the lexical conjugation is load-bearing. A fresh imperative +/// subject (`you`) after the same multi-target head must remain controller-only. +#[test] +fn multi_target_player_subject_does_not_capture_explicit_controller_draw() { + let def = parse_effect_chain( + "Any number of target opponents each discard their hands, then you draw a card.", + AbilityKind::Spell, + ); + let controller_draw = def + .sub_ability + .as_ref() + .expect("the explicit controller draw must remain a continuation"); + assert!(matches!( + &*controller_draw.effect, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } + )); +} + #[test] fn windfall_draw_uses_previous_discard_max_for_each_player() { let def = parse_effect_chain( diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 621d6f14a3..aafee45a3d 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1126,6 +1126,7 @@ mod vannifar_cloak_from_hand; mod veteran_bodyguard_tap_redirect; mod vohar_discard_drain; mod weeping_angel_combat_prevention; +mod wheel_and_deal; mod where_x_coverage_runtime; mod where_x_quantity_channel_binds; mod where_x_totality_guard; diff --git a/crates/engine/tests/integration/wheel_and_deal.rs b/crates/engine/tests/integration/wheel_and_deal.rs new file mode 100644 index 0000000000..d32596dddd --- /dev/null +++ b/crates/engine/tests/integration/wheel_and_deal.rs @@ -0,0 +1,130 @@ +//! Wheel and Deal's targeted-opponent instruction is distinct from its final +//! controller-only draw. The multi-target fan-out must repeat only the former. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::phase::Phase; +use engine::types::PlayerId; + +// Verified against Scryfall's Oracle text. +const WHEEL_AND_DEAL: &str = + "Any number of target opponents each discard their hands, then draw seven cards.\nDraw a card."; + +/// CR 601.2c + CR 608.2c: each selected opponent performs the first +/// instruction, then the spell's controller performs its final draw exactly +/// once after all selected opponents are finished. +#[test] +fn wheel_and_deal_fans_out_opponents_without_repeating_controller_draw() { + let p2 = PlayerId(2); + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + + let wheel_and_deal = scenario + .add_spell_to_hand_from_oracle(P0, "Wheel and Deal", true, WHEEL_AND_DEAL) + .id(); + scenario.with_cards_in_hand(P1, &["P1 discard A", "P1 discard B"]); + scenario.with_cards_in_hand(p2, &["P2 discard A", "P2 discard B"]); + scenario.with_library_top( + P0, + &[ + "Controller draw 1", + "Controller draw 2", + "Controller draw 3", + "Controller draw 4", + "Controller draw 5", + "Controller draw 6", + "Controller draw 7", + "Controller draw 8", + ], + ); + scenario.with_library_top( + P1, + &[ + "P1 draw 1", + "P1 draw 2", + "P1 draw 3", + "P1 draw 4", + "P1 draw 5", + "P1 draw 6", + "P1 draw 7", + ], + ); + scenario.with_library_top( + p2, + &[ + "P2 draw 1", + "P2 draw 2", + "P2 draw 3", + "P2 draw 4", + "P2 draw 5", + "P2 draw 6", + "P2 draw 7", + ], + ); + + let mut runner = scenario.build(); + let commit = runner + .cast(wheel_and_deal) + .target_players(&[P1, p2]) + .commit(); + let ability = commit + .state() + .stack + .last() + .and_then(|item| item.ability()) + .expect("Wheel and Deal must be on the stack"); + assert_eq!( + ability.targets, + vec![ + engine::types::ability::TargetRef::Player(P1), + engine::types::ability::TargetRef::Player(p2), + ], + "the cast pipeline must preserve both chosen opponents" + ); + let outcome = commit.resolve(); + + outcome.assert_hand_drawn(P0, 1); + outcome.assert_hand_drawn(P1, 5); + outcome.assert_hand_drawn(p2, 5); + assert_eq!( + outcome + .state() + .players + .iter() + .find(|player| player.id == P1) + .expect("P1 exists") + .graveyard + .len(), + 2, + "P1's whole hand must be discarded before drawing seven" + ); + assert_eq!( + outcome + .state() + .players + .iter() + .find(|player| player.id == p2) + .expect("P2 exists") + .graveyard + .len(), + 2, + "P2's whole hand must be discarded before drawing seven" + ); +} + +/// CR 601.2c + CR 608.2c: "Any number" permits no targets. That skips the +/// opponent-qualified instruction while preserving the independent final draw. +#[test] +fn wheel_and_deal_with_no_targets_draws_only_for_its_controller() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let wheel_and_deal = scenario + .add_spell_to_hand_from_oracle(P0, "Wheel and Deal", true, WHEEL_AND_DEAL) + .id(); + scenario.with_library_top(P0, &["Controller draw"]); + + let mut runner = scenario.build(); + let outcome = runner.cast(wheel_and_deal).target_players(&[]).resolve(); + + outcome.assert_hand_drawn(P0, 1); + outcome.assert_hand_drawn(P1, 0); +} diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 82b1887fcf..74f65b1373 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -1817,7 +1817,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Wandering Wolf - Warren Pilferers - Wei Assassins -- Wheel and Deal - Wicked Slumber - Wildcall - Will of the Abzan