diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index d72374d624..2c18b2e8f2 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3860,6 +3860,13 @@ fn effect_references_tracked_set(effect: &Effect) -> bool { return true; } } + if let Effect::SearchLibrary { filter, .. } = effect { + if filter_references_tracked_set(filter) + || filter_properties_reference_tracked_quantity(filter) + { + return true; + } + } if let Effect::GoadAll { target } = effect { if filter_references_tracked_set(target) { return true; @@ -3972,6 +3979,46 @@ fn filter_references_tracked_set(filter: &TargetFilter) -> bool { } } +/// CR 608.2c + CR 202.3: A search filter whose typed properties carry a +/// dynamic quantity (`FilterProp::Cmc { value: Ref(FilteredTrackedSetSize) }`, +/// Hunger Tide Rises chapter IV) consumes the chain tracked set even when the +/// filter itself is not a bare `TrackedSet`/`TrackedSetFiltered` reference. +fn filter_properties_reference_tracked_quantity(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Typed(tf) => tf + .properties + .iter() + .any(filter_prop_references_tracked_quantity), + TargetFilter::Not { filter } => filter_properties_reference_tracked_quantity(filter), + TargetFilter::Or { filters } | TargetFilter::And { filters } => filters + .iter() + .any(filter_properties_reference_tracked_quantity), + _ => false, + } +} + +/// CR 608.2c: Dynamic `Counters`, `Cmc`, and `PtComparison` values are all +/// quantity-bearing search properties. Keep their tracked-set detection at the +/// property boundary so a parent producer publishes its set for every axis. +fn filter_prop_references_tracked_quantity(prop: &crate::types::ability::FilterProp) -> bool { + match prop { + crate::types::ability::FilterProp::Counters { count, .. } => { + quantity_expr_references_tracked_set(count) + } + crate::types::ability::FilterProp::Cmc { value, .. } + | crate::types::ability::FilterProp::PtComparison { value, .. } => { + quantity_expr_references_tracked_set(value) + } + crate::types::ability::FilterProp::AnyOf { props } => { + props.iter().any(filter_prop_references_tracked_quantity) + } + crate::types::ability::FilterProp::Not { prop } => { + filter_prop_references_tracked_quantity(prop) + } + _ => false, + } +} + fn effect_uses_implicit_tracked_set_targets(effect: &Effect) -> bool { matches!( effect, @@ -6173,7 +6220,18 @@ fn perform_player_scope_sacrifices( .expect("a game cannot announce more sacrifices than i32 can represent"), ); if completion.publish_fresh_tracked_set { - publish_fresh_tracked_set(state, completion.sacrificed.clone()); + let set_id = publish_fresh_tracked_set(state, completion.sacrificed.clone()); + // CR 608.2c + CR 701.21a: an interactive sacrifice that publishes a + // fresh tracked set for a chained "sacrificed this way" consumer must + // stamp the Sacrificed cause on each member — otherwise + // `FilteredTrackedSetSize { caused_by: Sacrificed }` reads 0 (Hunger + // Tide Rises chapter IV, #5977). + if matches!(completion.effect_kind, Some(EffectKind::Sacrifice)) { + let causes = state.tracked_set_member_causes.entry(set_id).or_default(); + for id in &completion.sacrificed { + causes.insert(*id, ThisWayCause::Sacrificed); + } + } } if completion.propagate_parent_context { if let Some(snapshot) = @@ -10521,7 +10579,7 @@ mod tests { use crate::types::actions::GameAction; use crate::types::card::CardFace; use crate::types::card_type::CoreType; - use crate::types::counter::CounterType; + use crate::types::counter::{CounterMatch, CounterType}; use crate::types::format::FormatConfig; use crate::types::game_state::{ AutoMayChoice, CastingVariant, ExileLink, ExileLinkKind, LKISnapshot, LinkedExileSnapshot, @@ -10536,6 +10594,46 @@ mod tests { use crate::types::triggers::TriggerMode; use crate::types::zones::Zone; + #[test] + fn search_filter_dynamic_property_axes_consume_the_tracked_set() { + let tracked = || QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize, + }; + let properties = [ + FilterProp::Cmc { + comparator: Comparator::LE, + value: tracked(), + }, + FilterProp::Counters { + counters: CounterMatch::Any, + comparator: Comparator::LE, + count: tracked(), + }, + FilterProp::PtComparison { + stat: crate::types::ability::PtStat::Power, + scope: crate::types::ability::PtValueScope::Current, + comparator: Comparator::LE, + value: tracked(), + }, + ]; + + for property in properties { + let effect = Effect::SearchLibrary { + source_zones: vec![Zone::Library], + filter: TargetFilter::Typed(TypedFilter::creature().properties(vec![property])), + count: QuantityExpr::Fixed { value: 1 }, + reveal: false, + target_player: None, + selection_constraint: crate::types::ability::SearchSelectionConstraint::None, + split: None, + }; + assert!( + effect_references_tracked_set(&effect), + "dynamic search filter property must make its parent publish the tracked set: {effect:?}" + ); + } + } + /// CR 608.2c: a single tapped creature becomes the resolution's anaphoric /// referent, so a later "that creature's power" (Enlist) reads it. #[test] diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 380921dee4..c780fa22e5 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -165,16 +165,41 @@ fn is_search_result_reveal_clause(lower: &str) -> bool { ) } +/// CR 701.23a + CR 701.18a: Bare "put it onto the battlefield" restatement +/// after a search compound — library-only (Assassin's Trophy comma-split) or +/// multi-zone (The Hunger Tide Rises: "library and/or graveyard … and put it +/// onto the battlefield"). Composed from independent verb/pronoun/tapped axes +/// so Field-of-Ruin "puts it" and Winds-of-Abandon "those cards tapped" share +/// one grammar instead of N! enumerated sentences. +fn parse_search_result_put_onto_battlefield_restatement( + input: &str, +) -> Result<(&str, ()), nom::Err>> { + let (input, _) = alt((tag::<_, _, OracleError<'_>>("put "), tag("puts "))).parse(input)?; + let (input, _) = alt(( + tag("that card "), + tag("it "), + tag("them "), + tag("those cards "), + )) + .parse(input)?; + let (input, _) = tag("onto the battlefield").parse(input)?; + let (input, _) = opt(tag(" tapped")).parse(input)?; + Ok((input, ())) +} + +/// CR 701.23a + CR 701.18a: Bare "put it onto the battlefield" restatement +/// after a search compound. Shared by the bare-`and` split suppressor and the +/// SearchDestination follow-up absorber. +fn is_search_result_put_onto_battlefield_restatement(lower: &str) -> bool { + let bare = strip_search_result_subject(lower.trim().trim_end_matches('.')); + parse_search_result_put_onto_battlefield_restatement(bare) + .map(|(rest, _)| rest.trim().is_empty()) + .unwrap_or(false) +} + fn has_conditional_search_result_destination(lower: &str) -> bool { fn parse_clause(input: &str) -> Result<(&str, ()), nom::Err>> { - let (input, _) = alt(( - tag::<_, _, OracleError<'_>>("put that card onto the battlefield"), - tag("put it onto the battlefield"), - tag("put them onto the battlefield"), - tag("put those cards onto the battlefield"), - )) - .parse(input)?; - let (input, _) = opt(tag(" tapped")).parse(input)?; + let (input, _) = parse_search_result_put_onto_battlefield_restatement(input)?; let (input, _) = alt((tag(" if it's "), tag(" if it is "))).parse(input)?; let (input, _) = take_until(" card").parse(input)?; let (input, _) = tag(" card").parse(input)?; @@ -1096,6 +1121,15 @@ pub(super) fn split_clause_sequence(text: &str) -> Vec { && tag::<_, _, OracleError<'_>>("exile them") .parse(remainder_trimmed) .is_ok(); + // CR 701.23a + CR 701.18a: "search [library and/or graveyard] + // for … and put it onto the battlefield" is one search + // compound (The Hunger Tide Rises). Without this guard the + // bare-`and` splitter peels the put-step into a sibling + // ChangeZone that duplicates the SearchDestination + // continuation — and multi-zone puts use `origin: None`, + // so the follow-up absorber's library-only arm misses them. + let search_put_destination = has_search_prefix + && is_search_result_put_onto_battlefield_restatement(remainder_trimmed); // CR 707.9: ", except and [and …]" — inside // a copy-effect except clause, " and " is an internal // delimiter between recognised body shapes (SetName, P/T, @@ -1290,6 +1324,7 @@ pub(super) fn split_clause_sequence(text: &str) -> Vec { || targeted_compound_continuation || prevent_then_put_continuation || search_with_that_name + || search_put_destination || inside_except_clause || choice_partition_remainder || compound_subject_each @@ -6877,27 +6912,11 @@ pub(super) fn parse_followup_continuation_ast( // prefix-strip on the player-subject so all (subject × pronoun × tapped) // permutations match without N! enumerated arms. Effect::ChangeZone { - origin: Some(Zone::Library), + origin, destination: Zone::Battlefield, .. - } if { - let bare = strip_search_result_subject(lower.trim().trim_end_matches('.')); - matches!( - bare, - "put that card onto the battlefield" - | "put it onto the battlefield" - | "puts that card onto the battlefield" - | "puts it onto the battlefield" - | "put them onto the battlefield" - | "put those cards onto the battlefield" - | "put that card onto the battlefield tapped" - | "put it onto the battlefield tapped" - | "puts that card onto the battlefield tapped" - | "puts it onto the battlefield tapped" - | "put them onto the battlefield tapped" - | "put those cards onto the battlefield tapped" - ) - } => + } if matches!(origin, None | Some(Zone::Library)) + && is_search_result_put_onto_battlefield_restatement(&lower) => { Some(ContinuationAst::SearchResultClauseHandled) } @@ -8156,6 +8175,103 @@ mod tests { assert_eq!(chunks.len(), 1, "unexpected split: {chunks:?}"); } + #[test] + fn bare_and_keeps_multi_zone_search_put_onto_battlefield_compound() { + // CR 701.23a (issue #5977): The Hunger Tide Rises — "search your library + // and/or graveyard for … and put it onto the battlefield" must stay one + // compound so SearchDestination owns the put-step (origin: None). + let chunks = clause_texts( + "search your library and/or graveyard for a creature card with mana value less than or equal to the number of creatures sacrificed this way and put it onto the battlefield", + ); + assert_eq!(chunks.len(), 1, "unexpected split: {chunks:?}"); + } + + #[test] + fn search_result_put_restatement_covers_verb_pronoun_tapped_axes() { + for phrase in [ + "put that card onto the battlefield", + "put it onto the battlefield", + "puts that card onto the battlefield", + "puts it onto the battlefield", + "put them onto the battlefield", + "put those cards onto the battlefield", + "put that card onto the battlefield tapped", + "puts it onto the battlefield tapped", + ] { + assert!( + is_search_result_put_onto_battlefield_restatement(phrase), + "expected restatement match for {phrase:?}" + ); + } + for phrase in [ + "that player put it onto the battlefield", + "those players put those cards onto the battlefield tapped", + "each player puts them onto the battlefield", + ] { + assert!( + is_search_result_put_onto_battlefield_restatement(phrase), + "expected subject-stripped restatement match for {phrase:?}" + ); + } + for phrase in [ + "put it into your hand", + "put that card on top of your library", + "exile it", + ] { + assert!( + !is_search_result_put_onto_battlefield_restatement(phrase), + "must not match non-battlefield put restatement {phrase:?}" + ); + } + } + + #[test] + fn search_result_put_restatement_parses_independent_axes() { + let verbs = ["put ", "puts "]; + let pronouns = ["that card ", "it ", "them ", "those cards "]; + for verb in verbs { + for pronoun in pronouns { + for tapped in [false, true] { + let mut phrase = format!("{verb}{pronoun}onto the battlefield"); + if tapped { + phrase.push_str(" tapped"); + } + let (rest, _) = parse_search_result_put_onto_battlefield_restatement(&phrase) + .unwrap_or_else(|_| panic!("failed to parse {phrase:?}")); + assert!( + rest.is_empty(), + "parser must consume full restatement for {phrase:?}, leftover {rest:?}" + ); + } + } + } + } + + #[test] + fn multi_zone_search_put_restatement_absorbed_after_origin_none_destination() { + let previous = Effect::ChangeZone { + origin: None, + destination: Zone::Battlefield, + target: TargetFilter::Any, + 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 result = parse_followup_continuation_ast( + "put it onto the battlefield", + &previous, + &mut ParseContext::default(), + ); + assert_eq!(result, Some(ContinuationAst::SearchResultClauseHandled)); + } + #[test] fn search_exile_them_followup_is_absorbed_after_library_exile_destination() { let previous = Effect::ChangeZone { diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 99ca9ccd38..90b7554f08 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -12206,6 +12206,110 @@ fn assassins_trophy_its_controller_may_search_chain() { ); } +/// CR 701.21a + CR 701.23a + CR 608.2c (issue #5977): The Hunger Tide Rises +/// chapter IV — sacrifice any number, then search library and/or graveyard for +/// a creature with CMC ≤ sacrificed count and put it onto the battlefield, +/// then conditionally shuffle. Must lower to one multi-zone SearchLibrary with +/// a single origin-None battlefield put-step — not a duplicate ParentTarget +/// ChangeZone from a bare-`and` split on "and put it onto the battlefield". +#[test] +fn hunger_tide_rises_chapter_iv_sacrifice_search_put_chain() { + use crate::types::ability::{AbilityKind, Comparator, FilterProp, QuantityRef, ThisWayCause}; + + let def = parse_effect_chain( + "Sacrifice any number of creatures. Search your library and/or graveyard for a creature card with mana value less than or equal to the number of creatures sacrificed this way and put it onto the battlefield. If you search your library this way, shuffle.", + AbilityKind::Spell, + ); + + assert!( + matches!(&*def.effect, Effect::Sacrifice { .. }), + "top-level should be Sacrifice, got {:?}", + def.effect + ); + + let search = def + .sub_ability + .as_deref() + .expect("Sacrifice should chain SearchLibrary"); + match &*search.effect { + Effect::SearchLibrary { + source_zones, + filter, + .. + } => { + assert_eq!( + *source_zones, + vec![Zone::Graveyard, Zone::Library], + "multi-zone tutor must search graveyard and library" + ); + assert!( + matches!( + filter, + TargetFilter::Typed(typed) + if typed.type_filters.contains(&TypeFilter::Creature) + && typed.properties.iter().any(|p| matches!( + p, + FilterProp::Cmc { + comparator: Comparator::LE, + value: QuantityExpr::Ref { + qty: QuantityRef::FilteredTrackedSetSize { + caused_by: Some(ThisWayCause::Sacrificed), + .. + } + } + } + )) + ), + "search filter must cap CMC at sacrificed-this-way count: {filter:?}" + ); + } + other => panic!("expected SearchLibrary, got {other:?}"), + } + + let put = search + .sub_ability + .as_deref() + .expect("SearchLibrary should chain battlefield put-step"); + match &*put.effect { + Effect::ChangeZone { + origin, + destination, + target, + .. + } => { + assert_eq!( + *origin, None, + "multi-zone search put must use origin None (card may be found in graveyard)" + ); + assert_eq!(*destination, Zone::Battlefield); + assert_eq!(*target, TargetFilter::Any); + } + other => panic!("expected ChangeZone put-step, got {other:?}"), + } + + assert!( + !matches!( + put.sub_ability.as_deref().map(|d| &*d.effect), + Some(Effect::ChangeZone { + destination: Zone::Battlefield, + target: TargetFilter::ParentTarget, + .. + }) + ), + "must not duplicate the put-step with a stray ParentTarget ChangeZone" + ); + + let shuffle = put + .sub_ability + .as_deref() + .expect("put-step should chain conditional shuffle"); + assert!( + matches!(&*shuffle.effect, Effect::Shuffle { .. }), + "expected Shuffle tail, got {:?}", + shuffle.effect + ); +} + /// CR 608.2c + CR 117.3a + CR 701.23a + CR 603.7: Winds of /// Abandon — iterated subject-anchored search. The structure mirrors /// Assassin's Trophy but the search step carries `repeat_for: diff --git a/crates/engine/tests/integration/issue_5977_hunger_tide_chapter_iv.rs b/crates/engine/tests/integration/issue_5977_hunger_tide_chapter_iv.rs new file mode 100644 index 0000000000..95de0125c0 --- /dev/null +++ b/crates/engine/tests/integration/issue_5977_hunger_tide_chapter_iv.rs @@ -0,0 +1,303 @@ +//! Integration regression for GitHub issue #5977 — The Hunger Tide Rises, +//! chapter IV. +//! +//! Oracle: "Sacrifice any number of creatures. Search your library and/or +//! graveyard for a creature card with mana value less than or equal to the +//! number of creatures sacrificed this way and put it onto the battlefield. +//! If you search your library this way, shuffle." +//! +//! Reported bug: the search/pick step misbehaved — a bare-`and` split left a +//! stray `ChangeZone { target: ParentTarget }` sibling that could duplicate the +//! battlefield put during resolution. +//! +//! Parser regressions in `oracle_effect` assert the lowered AST shape; this +//! module drives the production Saga pipeline: lore-counter turn-based action → +//! chapter-IV `CounterAdded` trigger on the stack → sacrifice choice → +//! multi-zone `SearchChoice` → single battlefield arrival for the picked card. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::triggers::drain_order_triggers_with_identity; +use engine::game::zones::create_object; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const HUNGER_TIDE_ORACLE: &str = "(As this Saga enters and after your draw step, add a lore counter. Sacrifice after IV.)\nI, II, III — Create a 1/1 black and green Insect creature token.\nIV — Sacrifice any number of creatures. Search your library and/or graveyard for a creature card with mana value less than or equal to the number of creatures sacrificed this way and put it onto the battlefield. If you search your library this way, shuffle."; + +fn add_library_creature( + state: &mut GameState, + card_id: u64, + player: PlayerId, + name: &str, + mana_cost: ManaCost, +) -> ObjectId { + let id = create_object( + state, + CardId(card_id), + player, + name.to_string(), + Zone::Library, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.mana_cost = mana_cost.clone(); + obj.base_mana_cost = mana_cost; + id +} + +fn battlefield_creature_count(state: &GameState) -> usize { + state + .objects + .values() + .filter(|obj| { + obj.zone == Zone::Battlefield && obj.card_types.core_types.contains(&CoreType::Creature) + }) + .count() +} + +fn lore_count(runner: &GameRunner, saga_id: ObjectId) -> u32 { + runner + .state() + .objects + .get(&saga_id) + .and_then(|obj| obj.counters.get(&CounterType::Lore).copied()) + .unwrap_or(0) +} + +/// Park the game so the next precombat main phase belongs to P0 again and CR +/// 714.3c can add the Saga's fourth lore counter (mirrors `three_blind_mice`). +fn park_for_next_p0_precombat_main(runner: &mut GameRunner) { + let state = runner.state_mut(); + state.turn_number = 1; + state.active_player = P0; + state.phase = Phase::End; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; +} + +fn trigger_chapter_iv_via_saga_lore_counter(runner: &mut GameRunner, saga_id: ObjectId) { + park_for_next_p0_precombat_main(runner); + runner.advance_to_phase(Phase::PreCombatMain); // P1 precombat main (turn 2) + runner.pass_both_players(); + runner.advance_to_phase(Phase::PreCombatMain); // P0 precombat main (turn 3) + + assert_eq!( + lore_count(runner, saga_id), + 4, + "CR 714.3c must add the Saga's fourth lore counter before chapter IV fires" + ); + assert!( + !runner.state().stack.is_empty(), + "chapter IV CounterAdded trigger must be on the stack" + ); +} + +fn pass_priority_while_legal(runner: &mut GameRunner) { + if matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) { + let _ = runner.act(GameAction::PassPriority); + let _ = runner.act(GameAction::PassPriority); + } +} + +fn drain_order_triggers(runner: &mut GameRunner) { + if matches!(runner.state().waiting_for, WaitingFor::OrderTriggers { .. }) { + drain_order_triggers_with_identity(runner.state_mut()); + } +} + +fn resolve_chapter_iv_choices( + runner: &mut GameRunner, + sacrifice: &[ObjectId], + search_pick: ObjectId, +) { + for _ in 0..64 { + drain_order_triggers(runner); + + match &runner.state().waiting_for { + WaitingFor::EffectZoneChoice { + player, + count, + min_count, + up_to, + cards, + .. + } => { + assert_eq!(*player, P0); + assert_eq!(*min_count, 0, "any number includes zero (CR 107.1c)"); + assert!(*up_to, "Sacrifice any number uses variable selection"); + assert!( + *count >= sacrifice.len(), + "eligible sacrifice pool must cover chosen permanents" + ); + for id in sacrifice { + assert!( + cards.contains(id), + "sacrifice candidate {id:?} must be legal, got {cards:?}" + ); + } + runner + .act(GameAction::SelectCards { + cards: sacrifice.to_vec(), + }) + .expect("sacrifice selection accepted"); + assert_eq!( + runner.state().last_effect_count, + Some(sacrifice.len() as i32), + "sacrificed-this-way count must stamp for the CMC cap" + ); + for id in sacrifice { + assert!( + runner.state().players[0].graveyard.contains(id), + "sacrificed creature {id:?} must be in graveyard" + ); + } + } + WaitingFor::SearchChoice { + player, + cards, + count, + .. + } => { + assert_eq!(*player, P0); + assert_eq!(*count, 1, "chapter IV finds exactly one creature card"); + assert!( + cards.contains(&search_pick), + "picked card must be a legal search candidate, got {cards:?}" + ); + runner + .act(GameAction::SelectCards { + cards: vec![search_pick], + }) + .expect("search pick resolves the put-step continuation"); + } + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + WaitingFor::Priority { .. } => pass_priority_while_legal(runner), + _ => pass_priority_while_legal(runner), + } + } + + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "chapter IV must finish after the search put (and shuffle when applicable), got {:?}", + runner.state().waiting_for + ); +} + +#[test] +fn hunger_tide_chapter_iv_library_search_puts_creature_once() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let fodder_a = scenario + .add_creature(P0, "Fodder A", 2, 2) + .with_mana_cost(ManaCost::generic(3)) + .id(); + let fodder_b = scenario + .add_creature(P0, "Fodder B", 2, 2) + .with_mana_cost(ManaCost::generic(3)) + .id(); + let saga_id = scenario + .add_creature(P0, "The Hunger Tide Rises", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Saga"]) + .from_oracle_text(HUNGER_TIDE_ORACLE) + .id(); + let plains = ["Plains"; 10]; + scenario.with_library_top(P0, &plains); + scenario.with_library_top(P1, &plains); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state + .objects + .get_mut(&saga_id) + .unwrap() + .counters + .insert(CounterType::Lore, 3); + let finder = add_library_creature(state, 20, P0, "Library Finder", ManaCost::generic(2)); + let too_expensive = add_library_creature(state, 21, P0, "Too Expensive", ManaCost::generic(5)); + + trigger_chapter_iv_via_saga_lore_counter(&mut runner, saga_id); + resolve_chapter_iv_choices(&mut runner, &[fodder_a, fodder_b], finder); + + let state = runner.state(); + assert_eq!( + state.objects[&finder].zone, + Zone::Battlefield, + "library pick must enter the battlefield" + ); + assert_eq!( + battlefield_creature_count(state), + 1, + "only the searched creature may remain on the battlefield" + ); + assert_eq!( + state.objects[&too_expensive].zone, + Zone::Library, + "over-CMC library card must stay in the library" + ); + assert!( + !state.players[0].library.contains(&finder), + "found card must leave the library" + ); +} + +#[test] +fn hunger_tide_chapter_iv_graveyard_search_puts_creature_once() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let fodder = scenario + .add_creature(P0, "Fodder", 3, 3) + .with_mana_cost(ManaCost::generic(3)) + .id(); + let graveyard_finder = scenario + .add_creature_to_graveyard(P0, "Graveyard Finder", 1, 1) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let saga_id = scenario + .add_creature(P0, "The Hunger Tide Rises", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Saga"]) + .from_oracle_text(HUNGER_TIDE_ORACLE) + .id(); + let plains = ["Plains"; 10]; + scenario.with_library_top(P0, &plains); + scenario.with_library_top(P1, &plains); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state + .objects + .get_mut(&saga_id) + .unwrap() + .counters + .insert(CounterType::Lore, 3); + let _library_also = add_library_creature(state, 30, P0, "Library Also", ManaCost::generic(1)); + + trigger_chapter_iv_via_saga_lore_counter(&mut runner, saga_id); + resolve_chapter_iv_choices(&mut runner, &[fodder], graveyard_finder); + + let state = runner.state(); + assert_eq!( + state.objects[&graveyard_finder].zone, + Zone::Battlefield, + "graveyard pick must enter the battlefield" + ); + assert_eq!( + battlefield_creature_count(state), + 1, + "only the searched creature may remain on the battlefield" + ); + assert!( + state.players[0].graveyard.contains(&fodder), + "sacrificed fodder stays in the graveyard" + ); + assert!( + !state.players[0].graveyard.contains(&graveyard_finder), + "found graveyard card must leave the graveyard" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 5b097b4df2..62950939cb 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -530,6 +530,7 @@ mod issue_5820_susan_foreman; mod issue_5821_psychic_paper_attach_choice; mod issue_583_vivi_ornitier_mana_source; mod issue_5972_tracked_set_token_cleanup; +mod issue_5977_hunger_tide_chapter_iv; mod issue_5983_sothera_dies_edict; mod issue_5984_aloy_discover_on_attack; mod issue_5988_braids_arisen_nightmare;