diff --git a/crates/engine/src/game/effects/cloak.rs b/crates/engine/src/game/effects/cloak.rs index b2a830f477..200c5294a1 100644 --- a/crates/engine/src/game/effects/cloak.rs +++ b/crates/engine/src/game/effects/cloak.rs @@ -45,6 +45,10 @@ pub fn resolve( _ => return Err(EffectError::MissingParam("count".to_string())), }; + // CR 608.2c: this instruction owns the chain's referent slot from here on, + // including the arms where it produces nothing. + crate::game::morph::begin_face_down_referent_production(state); + let player = super::resolve_player_for_context_ref(state, ability, &target); // CR 110.2a: resolve the cloaking-player override through the single // canonical authority shared with ChangeZone/ChangeZoneAll/Manifest. diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index ccab4574ab..919e0a53fd 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -72,6 +72,7 @@ pub(crate) fn complete_discard_to_graveyard( controller_override: None, enter_transformed: false, face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, enter_as_copy: None, discard_frame, applied, diff --git a/crates/engine/src/game/effects/manifest.rs b/crates/engine/src/game/effects/manifest.rs index 5be4e8fbee..133003e567 100644 --- a/crates/engine/src/game/effects/manifest.rs +++ b/crates/engine/src/game/effects/manifest.rs @@ -34,6 +34,10 @@ pub fn resolve( _ => return Err(EffectError::MissingParam("count".to_string())), }; + // CR 608.2c: this instruction owns the chain's referent slot from here on, + // including the arms where it produces nothing. + crate::game::morph::begin_face_down_referent_production(state); + // `player` is the LIBRARY OWNER (whose top cards are manifested), resolved // from `target`. `controller` is the optional CR 110.2a override for which // player the cards enter the battlefield under ("under your control"). diff --git a/crates/engine/src/game/effects/manifest_dread.rs b/crates/engine/src/game/effects/manifest_dread.rs index 1d9dd0a73e..62f30cd542 100644 --- a/crates/engine/src/game/effects/manifest_dread.rs +++ b/crates/engine/src/game/effects/manifest_dread.rs @@ -13,6 +13,10 @@ pub fn resolve( events: &mut Vec, ) -> Result<(), EffectError> { let player = ability.controller; + // CR 608.2c: this instruction owns the chain's referent slot from here on, + // including the arm below where the library is empty and it produces + // nothing. + crate::game::morph::begin_face_down_referent_production(state); let player_state = state .players diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 5ab2273f8c..3ba0671392 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1645,6 +1645,7 @@ mod tests { controller_override: None, enter_with_counters: Vec::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, attach_to: None, library_placement: None, exile_duration: None, @@ -1663,6 +1664,7 @@ mod tests { controller_override: None, enter_with_counters: Vec::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, attach_to: None, library_placement: None, exile_duration: None, diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index cb7ed9bf29..63e3c6e751 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -788,6 +788,7 @@ pub fn route_debug_create_to_battlefield( controller_override: None, enter_transformed: false, face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, enter_as_copy: None, discard_frame: None, applied: HashSet::new(), diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index b86c6b2309..5e0806d287 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -4411,6 +4411,7 @@ mod tests { discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let mut events = Vec::new(); crate::game::sacrifice::apply_sacrifice_after_replacement(&mut state, event, &mut events); @@ -6330,6 +6331,7 @@ mod tests { discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let result = replacement_mod::replace_event(&mut state, proposed, &mut events); let ReplacementResult::NeedsChoice(player) = result else { @@ -6536,6 +6538,7 @@ mod tests { discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let result = replacement_mod::replace_event(&mut state, proposed, &mut events); let ReplacementResult::NeedsChoice(player) = result else { @@ -6658,6 +6661,7 @@ mod tests { discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let result = replacement_mod::replace_event(&mut state, proposed, &mut events); let ReplacementResult::NeedsChoice(player) = result else { @@ -7115,6 +7119,7 @@ mod tests { discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let result = replacement_mod::replace_event(&mut state, proposed, &mut events); let ReplacementResult::Execute(event) = result else { diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index aa677b3b58..bca587bcfb 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1830,7 +1830,10 @@ pub(super) fn handle_resolution_choice( Zone::Battlefield, source_id, ) - .face_down(face_down), + // CR 608.2c + CR 701.62a: the same producer as `manifest_card`, + // reached through the two-card choice instead of synchronously. + .face_down(face_down) + .publishing_chain_referent(), events, ) { crate::game::zone_pipeline::ZoneMoveResult::Done => {} diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index dfa4f393ce..8168e097a6 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -547,19 +547,66 @@ pub fn manifest_card( // control instead of the library owner's (Cybership routes the damaged // player's cards under the Cybership controller). The move is attributed to // `source_id` (the manifesting spell/ability), not the moved object. + // CR 608.2c: manifest (CR 701.40a), manifest dread (CR 701.62a) and cloak + // (CR 701.58a) all reach the battlefield through this one request, and they + // are exactly the three effects the parser admits as chain-referent + // producers (`oracle_effect::lower::publishes_chain_created_referent`). The + // mark lives here, on the producer, rather than in a list of causes or call + // sites that a fourth face-down delivery could silently join. let mut request = super::zone_pipeline::ZoneMoveRequest::effect(object_id, Zone::Battlefield, source_id) - .face_down(profile); + .face_down(profile) + .publishing_chain_referent(); if let Some(controller) = controller { request = request.under_control_of(controller); } match super::zone_pipeline::move_object(state, request, events) { + // Both arms are silent about the chain referent: the publish lives in + // `zone_pipeline::apply_face_down_entry_profile`, which the synchronous + // delivery and the CR 616.1 resume both run. super::zone_pipeline::ZoneMoveResult::Done => Ok(()), super::zone_pipeline::ZoneMoveResult::NeedsChoice(_) | super::zone_pipeline::ZoneMoveResult::NeedsAuraAttachmentChoice => Ok(()), } } +/// CR 608.2c: Record a face-down entrant as the chain's most-recent created +/// referent, so a following "it" / "that creature" anaphor +/// (`TargetFilter::LastCreated`) binds to it — "manifest dread, then attach +/// this Equipment to that creature" (Conductive Machete, #7531). +/// +/// This writes the SAME slot the token producer writes +/// (`engine_replacement.rs`, `state.last_created_token_ids`). The slot's name +/// says "token", but its job is the anaphor's referent, not a claim about +/// token-ness: CR 608.2c binds a demonstrative to the thing the previous +/// instruction produced, and a manifested card and a created token are the same +/// thing to that binding. The parser side keys on the same equivalence +/// (`publishes_chain_created_referent`). +/// +/// Assignment, not append — mirroring the token producer. The slot names the +/// MOST RECENT producer, so a chain that manifests twice leaves the second +/// entrant as the referent, which is what "that creature" means after it. +pub(crate) fn publish_face_down_entry_referent(state: &mut GameState, object_id: ObjectId) { + state.last_created_token_ids = vec![object_id]; +} + +/// CR 608.2c: a chain-referent producer that is about to run clears the slot. +/// +/// Without this, a producer that ends up producing NOTHING — manifest dread on +/// an empty library — would leave an EARLIER instruction's referent in place, +/// and the following demonstrative would bind to it. "Manifest dread, then +/// attach this Equipment to that creature" would equip a creature the sentence +/// never mentioned. +/// +/// Mirrors the token producer, which assigns `last_created_token_ids = +/// created_ids` at its tail whether or not the count was zero. The manifest +/// family cannot assign at its tail — a CR 616.1 entry pause can park the +/// delivery past the end of the resolver — so the two halves are split: the +/// producer clears up front, the successful delivery publishes. +pub(crate) fn begin_face_down_referent_production(state: &mut GameState) { + state.last_created_token_ids.clear(); +} + /// Find the object id of the top card of `player`'s library, if any. pub(crate) fn top_library_object( state: &GameState, diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index b0ded3ff4c..37f6b1d397 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1685,6 +1685,7 @@ fn discard_applier( controller_override: None, enter_transformed: false, face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, enter_as_copy: None, discard_frame, applied, @@ -11790,6 +11791,7 @@ mod tests { discard_frame: None, applied: HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let result = replace_event(&mut state, proposed, &mut events); let ReplacementResult::Execute(event) = result else { @@ -14019,6 +14021,7 @@ mod tests { discard_frame: None, applied: HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let result = replace_event(&mut state, proposed.clone(), &mut events); @@ -15238,6 +15241,7 @@ mod tests { discard_frame: None, applied: HashSet::new(), face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, }; let replaced = apply_single_replacement( @@ -18397,6 +18401,7 @@ mod tests { enter_transformed: false, enter_as_copy: None, face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, discard_frame: None, applied: HashSet::new(), }; @@ -18444,6 +18449,7 @@ mod tests { enter_transformed: false, enter_as_copy: None, face_down_profile: None, + chain_referent: crate::types::zones::ChainReferentIntent::Silent, discard_frame: None, applied: HashSet::new(), }; diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 05b4160e36..3faaa28845 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -158,6 +158,9 @@ pub struct EntryMods { pub enter_with_counters: Vec<(CounterType, u32)>, /// CR 708.2a + CR 708.3 face-down entry profile. pub face_down_profile: Option, + /// CR 608.2c: whether this entry is the producer a following demonstrative + /// anaphor refers back to. `Silent` unless a producer opted in. + pub chain_referent: crate::types::zones::ChainReferentIntent, /// CR 303.4f pre-resolved aura host. pub attach_to: Option, } @@ -240,6 +243,7 @@ impl ZoneMoveRequest { controller_override: self.mods.controller_override, enter_with_counters: self.mods.enter_with_counters, face_down_profile: self.mods.face_down_profile, + chain_referent: self.mods.chain_referent, attach_to: self.mods.attach_to, library_placement: self.placement, exile_duration: self.exile_links.duration, @@ -285,6 +289,7 @@ impl ZoneMoveRequest { controller_override: pending.controller_override, enter_with_counters: pending.enter_with_counters, face_down_profile: pending.face_down_profile, + chain_referent: pending.chain_referent, attach_to: pending.attach_to, }, placement: pending.library_placement, @@ -499,6 +504,14 @@ impl ZoneMoveRequest { self } + /// CR 608.2c: mark this entry as the producer a following demonstrative + /// anaphor binds to. Opt-in, so an unmarked delivery never touches the + /// game-lifetime referent slot. + pub fn publishing_chain_referent(mut self) -> Self { + self.mods.chain_referent = crate::types::zones::ChainReferentIntent::Publishes; + self + } + /// Library placement override (`LibraryPosition::Top` / `Bottom` / /// `NthFromTop`). Only meaningful when `to == Zone::Library`. pub fn at_library_position(mut self, position: LibraryPosition) -> Self { @@ -839,7 +852,13 @@ pub(crate) fn move_object_with_terminal( let source_id = req.source(); let mut proposed = ProposedEvent::zone_change(req.object_id, from_zone, Zone::Library, source_id); - if let ProposedEvent::ZoneChange { applied, .. } = &mut proposed { + if let ProposedEvent::ZoneChange { + applied, + chain_referent, + .. + } = &mut proposed + { + *chain_referent = req.mods.chain_referent; *applied = req.replacement_applied.clone(); } return match replacement::replace_event(state, proposed, events) { @@ -913,7 +932,13 @@ pub(crate) fn move_object_with_terminal( // `Draw` cause variant — no other cause produces one. if let ZoneChangeCause::Draw { seed_applied } = req.cause { let mut proposed = ProposedEvent::zone_change(req.object_id, from_zone, req.to, source_id); - if let ProposedEvent::ZoneChange { applied, .. } = &mut proposed { + if let ProposedEvent::ZoneChange { + applied, + chain_referent, + .. + } = &mut proposed + { + *chain_referent = req.mods.chain_referent; *applied = req.replacement_applied; applied.extend(seed_applied); } @@ -1007,6 +1032,7 @@ pub(crate) fn move_object_with_terminal( controller_override, enter_with_counters, face_down_profile, + chain_referent, applied, .. } = &mut proposed @@ -1019,6 +1045,7 @@ pub(crate) fn move_object_with_terminal( *controller_override = req.mods.controller_override; enter_with_counters.extend(req.mods.enter_with_counters.iter().cloned()); *face_down_profile = req.mods.face_down_profile.clone().map(Box::new); + *chain_referent = req.mods.chain_referent; *applied = req.replacement_applied; } let approved = ApprovedZoneChange::seal(proposed); @@ -1060,6 +1087,7 @@ pub(crate) fn move_object_with_terminal( req.mods.controller_override, &req.mods.enter_with_counters, req.mods.face_down_profile.as_ref(), + req.mods.chain_referent, track_exiled_by_source, None, None, @@ -1439,6 +1467,7 @@ fn anticipated_zone_change_delivery( controller_override, enter_with_counters, face_down_profile, + chain_referent, attach_to, applied, .. @@ -1450,6 +1479,7 @@ fn anticipated_zone_change_delivery( *controller_override = request.mods.controller_override; *enter_with_counters = request.mods.enter_with_counters.clone(); *face_down_profile = request.mods.face_down_profile.clone().map(Box::new); + *chain_referent = request.mods.chain_referent; *attach_to = request.mods.attach_to; *applied = request.replacement_applied.clone(); } @@ -3323,6 +3353,7 @@ pub(crate) fn deliver_replaced_zone_change( enter_with_counters, controller_override: ctrl_override, face_down_profile, + chain_referent, enter_as_copy, discard_frame, applied, @@ -3692,6 +3723,24 @@ pub(crate) fn deliver_replaced_zone_change( if let Some(profile) = &face_down_profile { apply_face_down_entry_profile(state, object_id, profile); } + // CR 608.2c: a permanent the instruction just produced is the + // chain's most-recent created referent, so a following "it" / "that + // creature" anaphor (`TargetFilter::LastCreated`) binds to it — + // "manifest dread, then attach this Equipment to that creature" + // (#7531). + // + // Keyed on the intent the REQUEST carried, not on any property of + // the entrant: two effects can deliver an identical face-down + // permanent and only one of them be the producer the sentence + // refers back to. Published here rather than at the producing + // effect so the synchronous arm, the manifest-dread continuation + // and the CR 616.1 parked-entry resume all reach it — the intent + // rides the parked event with the rest of the request — and only + // once the entry has actually settled (`entered_battlefield`), so a + // `CantEnterBattlefieldFrom` rejection publishes nothing. + if chain_referent.publishes() { + crate::game::morph::publish_face_down_entry_referent(state, object_id); + } } // CR 614.12a + CR 616.1c + CR 707.2: An enter-as-copy replacement // selected its copy source before this delivery and carried those @@ -4087,6 +4136,7 @@ pub(crate) fn execute_zone_move_with_terminal_and_controller( controller_override, effect_enter_with_counters, face_down_profile, + crate::types::zones::ChainReferentIntent::Silent, track_exiled_by_source, library_placement, enter_attached_to, @@ -4110,6 +4160,11 @@ fn execute_zone_move_with_applied_terminal( controller_override: Option, effect_enter_with_counters: &[(CounterType, u32)], face_down_profile: Option<&crate::types::ability::FaceDownProfile>, + // CR 608.2c: whether this entry is the producer a following demonstrative + // anaphor binds to. Only `move_object_with_terminal` forwards a request's + // intent; the four public `execute_zone_move*` wrappers are raw movers with + // no originating instruction to speak for, and pass `Silent`. + chain_referent: crate::types::zones::ChainReferentIntent, track_exiled_by_source: bool, library_placement: Option, enter_attached_to: Option, @@ -4137,8 +4192,14 @@ fn execute_zone_move_with_applied_terminal( return ZoneMoveTerminalResult::Completed(ZoneMoveCompletion::Remained); } let mut proposed = ProposedEvent::zone_change(obj_id, from_zone, dest_zone, Some(source_id)); - if let ProposedEvent::ZoneChange { applied, .. } = &mut proposed { + if let ProposedEvent::ZoneChange { + applied, + chain_referent: ref mut intent, + .. + } = &mut proposed + { *applied = replacement_applied; + *intent = chain_referent; } // CR 712.14a: Set enter_transformed on the proposed event so replacement effects @@ -6256,3 +6317,44 @@ mod effect_driven_transformed_entry_tests { ); } } + +#[cfg(test)] +mod face_down_entry_referent_tests { + use super::*; + use crate::game::zones::create_object; + use crate::types::ability::FaceDownProfile; + use crate::types::identifiers::CardId; + use crate::types::player::PlayerId; + + /// CR 608.2c: the shared CR 708.3 helper installs characteristics and + /// NOTHING else. It is reached by every face-down path, including the + /// face-down CAST in `casting.rs` (where the object is on the stack and has + /// produced no permanent to name) and that module's two cast SIMULATIONS, + /// so a publish here would be a write no instruction asked for. + /// + /// The referent is published at the delivery instead, from the intent the + /// REQUEST carried — see `ChainReferentIntent`. What this row nails down is + /// the negative: no caller of this helper can publish by reaching it. + /// + /// It does NOT prove the positive. That is the integration suite's job + /// (`manifest_dread_that_creature_anaphor`), which drives the synchronous + /// manifest, the two-card continuation and the accept/decline resume through + /// the production pipeline. + #[test] + fn the_shared_face_down_helper_publishes_no_referent_from_any_zone() { + for zone in [Zone::Battlefield, Zone::Stack] { + let mut state = GameState::new_two_player(7); + let player = PlayerId(0); + let id = create_object(&mut state, CardId(1), player, "Entrant".to_string(), zone); + let before = vec![ObjectId(999)]; + state.last_created_token_ids = before.clone(); + + apply_face_down_entry_profile(&mut state, id, &FaceDownProfile::vanilla_2_2()); + + assert_eq!( + state.last_created_token_ids, before, + "the characteristics helper must not touch the referent slot (zone {zone:?})" + ); + } + } +} diff --git a/crates/engine/src/parser/oracle_effect/counter.rs b/crates/engine/src/parser/oracle_effect/counter.rs index 4de1577553..e7ad10d9ba 100644 --- a/crates/engine/src/parser/oracle_effect/counter.rs +++ b/crates/engine/src/parser/oracle_effect/counter.rs @@ -86,12 +86,39 @@ fn is_it_pronoun(text: &str) -> bool { /// form so this helper reproduces the legacy ungated bare-"it" binding exactly, /// making the it-branch refactor provably behavior-preserving. `None` ⇒ the /// caller applies its own default (source / parent / typed target). -pub(super) fn counter_anaphor_created_token_binding( +/// The demonstrative/definite half of the chain-created anaphor, WITHOUT the +/// bare object pronoun. +/// +/// "That creature" / "that token" / "the permanent" name the thing the previous +/// instruction produced and nothing else. Bare "it" does not: it is the general +/// anaphor, and every consumer already resolves it through its own subject-aware +/// authority (`resolve_it_pronoun`, `attach_neuter_recipient_resolves_via_subject`). +/// A consumer that wants the chain-created referent for a demonstrative must not +/// get bare "it" smuggled in with it, which is why this is a separate entry point +/// rather than a flag on [`counter_anaphor_created_token_binding`]. +pub(super) fn chain_created_demonstrative_binding( anaphor_lower: &str, ctx: &ParseContext, ) -> Option { - let it_pronoun = is_it_pronoun(anaphor_lower); - let demonstrative = nom_on_lower(anaphor_lower, anaphor_lower, |i| { + if !anaphor_is_chain_created_demonstrative(anaphor_lower) { + return None; + } + // Same gate the composed entry point applies to the demonstrative form: a + // non-self trigger subject re-anchors the reference to the triggering object + // (Pip-Boy 3000), and only a chain that actually produced something can be + // named at all. + let subject_allows_token = matches!( + ctx.subject, + None | Some(TargetFilter::SelfRef) | Some(TargetFilter::Any) + ); + (ctx.token_created_in_chain && subject_allows_token).then_some(TargetFilter::LastCreated) +} + +/// CR 608.2c: the demonstrative and definite back-reference forms. +/// `the creature` is deliberately EXCLUDED — it legitimately binds a chosen +/// target slot (Longstalk Brawl) and is genuinely ambiguous. +fn anaphor_is_chain_created_demonstrative(anaphor_lower: &str) -> bool { + nom_on_lower(anaphor_lower, anaphor_lower, |i| { value( (), alt(( @@ -104,7 +131,15 @@ pub(super) fn counter_anaphor_created_token_binding( ) .parse(i) }) - .is_some(); + .is_some() +} + +pub(super) fn counter_anaphor_created_token_binding( + anaphor_lower: &str, + ctx: &ParseContext, +) -> Option { + let it_pronoun = is_it_pronoun(anaphor_lower); + let demonstrative = anaphor_is_chain_created_demonstrative(anaphor_lower); if !it_pronoun && !demonstrative { return None; } diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 72693dc966..7e631d65dd 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -6038,6 +6038,22 @@ fn parse_attach_recipient<'a>(text: &'a str, ctx: &mut ParseContext) -> (TargetF { return (resolve_it_pronoun(ctx), &trimmed[lower.len()..]); } + // CR 608.2c: a DEMONSTRATIVE recipient after a clause that produced a + // permanent names that permanent — "manifest dread, then attach this + // Equipment to that creature" (Conductive Machete, #7531). Same anaphor + // and same authority the counter path already uses for the identical + // shape ("create a token, then put a counter on that creature"), so the + // two consumers cannot disagree about what "that creature" means. + // + // The DEMONSTRATIVE-only entry point is deliberate: bare "it" is already + // resolved by the branch above through + // `attach_neuter_recipient_resolves_via_subject`, a wider gate, and + // letting it fall through to here would change bare-pronoun attachment + // for chains that have nothing to do with a chain-created referent. + if let Some(bound) = super::counter::chain_created_demonstrative_binding(lower.trim(), ctx) + { + return (bound, &trimmed[lower.len()..]); + } } (target, rest) } diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index a3e84ac7f0..912ab9920b 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2631,6 +2631,29 @@ pub(super) fn is_token_creating_effect(effect: &Effect) -> bool { ) } +/// CR 608.2c + CR 701.40a + CR 701.58a + CR 701.62a: Does this clause put a NEW +/// permanent onto the battlefield that a later same-chain anaphor can name? +/// +/// A created token and a face-down entry are indistinguishable to the anaphor. +/// Both clauses produce exactly one new permanent and declare NO target, so a +/// following "it" / "that creature" has exactly one possible referent — the +/// thing the clause just made. Manifest (CR 701.40a), manifest dread +/// (CR 701.62a) and cloak (CR 701.58a) all route through the one runtime +/// producer (`game::morph::manifest_card`) and put a 2/2 face-down creature +/// onto the battlefield, exactly as `Effect::Token` puts a token there. +/// +/// Keying the chain-referent flag on "token" alone left the face-down producers +/// with no referent, so their anaphor fell through to `ParentTarget` (empty — +/// the producer has no targets) or to the trigger source: Conductive Machete +/// attached to nothing, Weight Room put its counters on the Room (#7531). +pub(super) fn publishes_chain_created_referent(effect: &Effect) -> bool { + is_token_creating_effect(effect) + || matches!( + effect, + Effect::Manifest { .. } | Effect::ManifestDread | Effect::Cloak { .. } + ) +} + /// CR 603.12 + CR 609.3: Re-link a clause that READS the just-created-token /// referent published by a clause under an AFFIRMATIVE reflexive gate. /// @@ -2670,11 +2693,19 @@ pub(super) fn is_token_creating_effect(effect: &Effect) -> bool { /// without the other re-opens the stale-`LastCreated` bind. The prediction's /// blind spot (assembly-time `SequentialSibling` minters) is enumerated on /// [`instruction_spine_is_continuation`]. +/// +/// The producer half is [`publishes_chain_created_referent`] — the SAME +/// predicate `chain_prior_referent_is_created_token` seeds `LastCreated` from, +/// and the same one `clone_would_transplant_gated_referent` re-asks. Three +/// passes, one question: a producer that can seed the referent must also be +/// able to relink its consumer, or a gated face-down producer seeds +/// `LastCreated` and then leaves the consumer a `SequentialSibling` that reads +/// the game-lifetime ledger when the gate is false. pub(super) fn relink_gated_token_referent_consumers(defs: &mut [AbilityDefinition]) { for i in 0..defs.len() { let Some(publisher) = defs[..i] .iter() - .rposition(|d| is_token_creating_effect(&d.effect)) + .rposition(|d| publishes_chain_created_referent(&d.effect)) else { continue; }; @@ -2940,7 +2971,7 @@ pub(super) fn clone_would_transplant_gated_referent( } let Some(publisher) = defs[..template] .iter() - .rposition(|d| is_token_creating_effect(&d.effect)) + .rposition(|d| publishes_chain_created_referent(&d.effect)) else { return false; }; diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 0f399a7dc4..a3d0d92570 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -44,7 +44,7 @@ use lower::{ extract_put_counter_multi_target, extract_remove_counter_multi_target, extract_switch_pt_multi_target, instruction_spine_is_continuation, is_token_creating_effect, parse_damage_player_scope, parse_for_each_opponent_target_fanout_clause, - rebind_clause_recipients_with, rebind_decline_body_recipient, + publishes_chain_created_referent, rebind_clause_recipients_with, rebind_decline_body_recipient, rebind_subject_only_body_recipient, scan_until_next_same_source_exile_invalidation, split_difference_repeat_suffix, strip_any_number_quantifier, strip_each_player_subject, strip_each_scope_who_cant_subject, strip_each_scope_who_didnt_verb_filter_this_way_subject, @@ -20967,7 +20967,7 @@ fn chain_prior_referent_is_created_token(clauses: &[ClauseIr]) -> bool { .sub_ability .as_deref() .is_none_or(instruction_spine_is_continuation); - if is_token_creating_effect(&prev.parsed.effect) { + if publishes_chain_created_referent(&prev.parsed.effect) { // CR 603.12: a GATED publisher may create no token, so seeding // `LastCreated` from it is safe only when // `relink_gated_token_referent_consumers` (lower.rs) will move the diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e7f975f92b..2d291eecd6 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -64,7 +64,7 @@ use super::resolved_commands::{ ResolvedRngReplayInvariantError, ResolvedRulesCommand, ResolvedRulesJournal, RulesExecutionNodeRef, }; -use super::zones::EtbTapState; +use super::zones::{ChainReferentIntent, EtbTapState}; use super::zones::{ExileCostSourceZone, Zone}; use crate::analysis::resource::{object_class, CounterClass, ObjectClass, ResourceAxis}; @@ -5074,6 +5074,10 @@ pub struct PendingBatchZoneMoveRequest { pub enter_with_counters: Vec<(CounterType, u32)>, #[serde(default, skip_serializing_if = "Option::is_none")] pub face_down_profile: Option, + /// Whether this parked entry is the producer a following demonstrative + /// anaphor refers back to. It remains with the request across a pause. + #[serde(default, skip_serializing_if = "ChainReferentIntent::is_silent")] + pub chain_referent: ChainReferentIntent, #[serde(default, skip_serializing_if = "Option::is_none")] pub attach_to: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 902bda9937..fa3f702753 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -19,7 +19,7 @@ use super::phase::Phase; use super::player::{PlayerCounterKind, PlayerId}; use super::zones::Zone; -pub use super::zones::EtbTapState; +pub use super::zones::{ChainReferentIntent, EtbTapState}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ReplacementId { @@ -456,6 +456,11 @@ pub enum ProposedEvent { /// `ProposedEvent` (and the `Result<_, ProposedEvent>` pipeline). #[serde(default, skip_serializing_if = "Option::is_none")] face_down_profile: Option>, + /// CR 608.2c: whether this entry is the producer a following + /// demonstrative anaphor binds to. Rides the event so a CR 616.1 + /// pause/resume delivers the same answer the effect asked for. + #[serde(default, skip_serializing_if = "ChainReferentIntent::is_silent")] + chain_referent: ChainReferentIntent, /// CR 614.12a + CR 616.1c + CR 707.2: Pre-entry copy payload for /// Mystic Reflection-style replacements. The copied values ride the /// event so later replacement passes can match the entering permanent @@ -809,6 +814,7 @@ impl ProposedEvent { controller_override: None, enter_transformed: false, face_down_profile: None, + chain_referent: ChainReferentIntent::default(), enter_as_copy: None, discard_frame: None, applied: HashSet::new(), diff --git a/crates/engine/src/types/zones.rs b/crates/engine/src/types/zones.rs index 2645bf46ba..55892a362a 100644 --- a/crates/engine/src/types/zones.rs +++ b/crates/engine/src/types/zones.rs @@ -62,6 +62,48 @@ impl ExileCostSourceZone { } } +/// CR 608.2c: whether a battlefield entry is the referent a following +/// demonstrative anaphor binds to ("manifest dread, then attach this Equipment +/// to **that creature**"). +/// +/// Carried on the entry request rather than derived at the delivery, because +/// the question is about the EFFECT that asked for the entry, not about the +/// permanent that arrives. Two effects can produce an identical face-down +/// permanent and only one of them be the producer the sentence refers back to, +/// so no property of the entrant — its zone, its face-down cause, its +/// characteristics — can answer it. +/// +/// [`Silent`] is the default on purpose: an entry that has not been marked is +/// not a producer, so a delivery path added later cannot silently start +/// overwriting the game-lifetime `last_created_token_ids` ledger. +/// +/// The engine's marked call sites and the parser's admission authority +/// (`oracle_effect::lower::publishes_chain_created_referent`) are the two halves +/// of one contract and have to be changed together. +/// +/// [`Silent`]: ChainReferentIntent::Silent +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum ChainReferentIntent { + /// This entry names nothing. Every path that has not opted in. + #[default] + Silent, + /// CR 608.2c: on successful delivery, this entrant becomes the chain's + /// most-recent created referent. + Publishes, +} + +impl ChainReferentIntent { + /// Whether a delivery carrying this intent publishes the referent. + pub fn publishes(self) -> bool { + matches!(self, Self::Publishes) + } + + /// Whether the intent is the default, for wire-shape preservation. + pub fn is_silent(&self) -> bool { + matches!(self, Self::Silent) + } +} + /// CR 614.1 / CR 110.5b: Whether an object enters the battlefield tapped. /// /// Canonical type for all enter-tapped fields across ability AST, game-state diff --git a/crates/engine/tests/integration/integration_bending.rs b/crates/engine/tests/integration/integration_bending.rs index df8e086f15..c648854d00 100644 --- a/crates/engine/tests/integration/integration_bending.rs +++ b/crates/engine/tests/integration/integration_bending.rs @@ -1945,6 +1945,7 @@ fn earthbend_return_skips_shock_land_pay_life_prompt() { controller_override: Some(P0), enter_transformed: false, face_down_profile: None, + chain_referent: engine::types::zones::ChainReferentIntent::Silent, enter_as_copy: None, discard_frame: None, applied: std::collections::HashSet::new(), @@ -2023,6 +2024,7 @@ fn plain_shock_land_etb_still_prompts_for_life_payment() { controller_override: None, enter_transformed: false, face_down_profile: None, + chain_referent: engine::types::zones::ChainReferentIntent::Silent, enter_as_copy: None, discard_frame: None, applied: std::collections::HashSet::new(), diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index b522435a4c..7321d8a115 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -837,6 +837,7 @@ mod mana_drain_refund; mod mana_payment_preview; mod mana_role_fixture_migration; mod mana_target_recipient_and_count_source; +mod manifest_dread_that_creature_anaphor; mod maraxus_team_pump_anthem; mod martial_impetus_other_attacker_exclusion_6017; mod mass_phase_out_1792_repro; diff --git a/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs b/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs new file mode 100644 index 0000000000..de5f77c860 --- /dev/null +++ b/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs @@ -0,0 +1,391 @@ +//! CR 608.2c + CR 701.62a: "manifest dread, then attach this Equipment to that +//! creature" — the demonstrative names the permanent the previous instruction +//! produced (#7531). +//! +//! Conductive Machete, Cursed Windbreaker, Dissection Tools and Killer's Mask +//! all print this line. Before the fix the anaphor fell through to +//! `TargetFilter::ParentTarget`, which resolves off the parent ability's +//! TARGETS — and `Effect::ManifestDread` declares none, so the set was empty +//! and the attach was a silent no-op. +//! +//! Two halves had to line up, and each test below is red without both: the +//! parser must bind the anaphor to `TargetFilter::LastCreated`, and the +//! face-down entry must publish itself into that referent slot the way a token +//! producer does. +//! +//! The same binding covers the plain-manifest sorceries — Fierce Invocation, +//! Formless Nurturing and Wildcall print "Manifest the top card of your +//! library, then put N +1/+1 counters on **it**", where the anaphor used to +//! bind to the SORCERY (`SelfRef`) and the counters went nowhere. Measured over +//! `client/public/card-data.json`: 7 cards change parse, these three plus the +//! four Equipment. +//! +//! Cards are built from Oracle text (CI has no card database). The Equipment +//! subtype is stamped explicitly: without it CR 301.5 makes the attachment +//! illegal and state-based actions unattach it again, which would make every +//! assertion here vacuous — the token control test is what proves the harness +//! attaches at all. + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; + +const MACHETE: &str = "When this Equipment enters, manifest dread, then attach this Equipment to that creature.\nEquipped creature gets +2/+1.\nEquip {4}"; + +/// The already-working sibling shape: a token producer with the same +/// "then attach this Equipment to it" continuation. +const ANCESTRAL_BLADE: &str = "When this Equipment enters, create a 1/1 white Soldier creature token, then attach this Equipment to it.\nEquipped creature gets +1/+1.\nEquip {3}"; + +/// An Equipment in hand with `oracle`, plus `library` library cards so manifest +/// dread has something to look at. +fn board(name: &str, oracle: &str, library: usize) -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + for i in 0..library { + scenario.add_card_to_library_top(P0, &format!("Library {i}")); + } + let equipment = scenario + .add_artifact_to_hand_from_oracle(P0, name, oracle) + .with_subtypes(vec!["Equipment"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool(P0, vec![]); + (scenario.build(), equipment) +} + +fn host_of(runner: &GameRunner, equipment: ObjectId) -> Option { + runner.state().objects[&equipment] + .attached_to + .as_ref() + .and_then(|attached| attached.as_object()) +} + +/// Conductive Machete: the Equipment attaches to the creature its own manifest +/// dread just produced. +/// +/// Manifest dread looks at two cards, so it parks +/// `WaitingFor::ManifestDreadChoice` and the manifested card enters from the +/// CONTINUATION — the arm that had no referent publish at all. +/// +/// Reverting either half turns this red: without the parser binding the attach +/// target stays `ParentTarget` and `attached_to` is `None`; without the publish +/// `LastCreated` resolves to a stale (or empty) id. +#[test] +fn the_equipment_attaches_to_the_creature_manifest_dread_produced() { + let (mut runner, machete) = board("Conductive Machete", MACHETE, 2); + let manifested = runner.state().players[0].library[0]; + + runner.cast(machete).resolve(); + runner.advance_until_stack_empty(); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::ManifestDreadChoice { .. } + ), + "manifest dread must pause for the two-card choice, got {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::SelectCards { + cards: vec![manifested], + }) + .expect("choose the card to manifest"); + runner.advance_until_stack_empty(); + + assert!( + runner.state().objects[&manifested].face_down, + "the chosen card must be on the battlefield face down" + ); + assert_eq!( + host_of(&runner, machete), + Some(manifested), + "the Machete must equip the creature it just manifested" + ); +} + +/// CR 609.3 counter-direction: with an EMPTY library manifest dread produces +/// nothing, so the anaphor has no referent and the Equipment stays unattached +/// rather than latching onto some earlier object. +/// +/// This is the guard against the stale-referent failure mode: `LastCreated` +/// reads a game-lifetime slot, so a fix that published the wrong thing would +/// show up here as an unexpected host. +#[test] +fn an_empty_library_manifests_nothing_and_attaches_nothing() { + let (mut runner, machete) = board("Conductive Machete", MACHETE, 0); + + runner.cast(machete).resolve(); + runner.advance_until_stack_empty(); + + assert_eq!( + host_of(&runner, machete), + None, + "with nothing manifested there is no creature to equip" + ); +} + +/// A board that plays a REAL producer first, so the referent slot already holds +/// something when the Machete's own producer runs. Both Equipment start in +/// hand; the Blade is cast and resolved, leaving its Soldier token as the +/// chain's most-recent referent. +fn board_with_a_prior_referent(library: usize) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + for i in 0..library { + scenario.add_card_to_library_top(P0, &format!("Library {i}")); + } + let blade = scenario + .add_artifact_to_hand_from_oracle(P0, "Ancestral Blade", ANCESTRAL_BLADE) + .with_subtypes(vec!["Equipment"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + let machete = scenario + .add_artifact_to_hand_from_oracle(P0, "Conductive Machete", MACHETE) + .with_subtypes(vec!["Equipment"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool(P0, vec![]); + let mut runner = scenario.build(); + + runner.cast(blade).resolve(); + runner.advance_until_stack_empty(); + let prior = host_of(&runner, blade).expect("setup: the Blade equips its own token"); + assert_eq!( + runner.state().last_created_token_ids, + vec![prior], + "setup: the prior producer left its token as the chain referent" + ); + + (runner, machete, prior) +} + +/// The reviewer's continuation case, accepted: with a PRIOR referent already in +/// the slot, the paused two-card choice must leave the newly manifested card as +/// the referent — not the token the previous instruction produced. +/// +/// The starting slot is non-empty on purpose. A test that starts empty cannot +/// tell "published the new entrant" from "retained whatever was there", because +/// both leave a slot that happens to be right. +#[test] +fn a_paused_continuation_overwrites_a_prior_referent() { + let (mut runner, machete, prior) = board_with_a_prior_referent(2); + let manifested = runner.state().players[0].library[0]; + + runner.cast(machete).resolve(); + runner.advance_until_stack_empty(); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::ManifestDreadChoice { .. } + ), + "manifest dread must pause for the two-card choice, got {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::SelectCards { + cards: vec![manifested], + }) + .expect("choose the card to manifest"); + runner.advance_until_stack_empty(); + + assert_eq!( + host_of(&runner, machete), + Some(manifested), + "the Machete equips what its OWN manifest dread produced" + ); + assert_ne!( + host_of(&runner, machete), + Some(prior), + "and never the earlier instruction's token" + ); + assert_eq!( + runner.state().last_created_token_ids, + vec![manifested], + "the slot names the most recent producer's output" + ); +} + +/// The same board, declined: manifest dread produces NOTHING, so the +/// demonstrative names nothing and the Equipment stays unattached. +/// +/// This is the row the stale-referent failure mode shows up in. `LastCreated` +/// is a game-lifetime slot, so a producer that runs and produces nothing must +/// leave it EMPTY — otherwise "that creature" silently reaches back to the +/// previous instruction's token and the Machete equips a creature the sentence +/// never mentioned. Without the producer's up-front clear this row equips +/// `prior`. +#[test] +fn a_producer_that_produces_nothing_does_not_leave_a_prior_referent_standing() { + let (mut runner, machete, prior) = board_with_a_prior_referent(0); + + runner.cast(machete).resolve(); + runner.advance_until_stack_empty(); + + assert_eq!( + host_of(&runner, machete), + None, + "nothing was manifested, so there is no creature to equip — and \ + certainly not {prior:?} from the previous instruction" + ); + assert!( + runner.state().last_created_token_ids.is_empty(), + "a producer that produced nothing leaves nothing behind, got {:?}", + runner.state().last_created_token_ids + ); +} + +/// The token sibling must keep working — and it is the reach guard for the +/// negative assertion above: if this harness could not attach an Equipment at +/// all, this test would fail too. +#[test] +fn the_token_producer_sibling_still_attaches() { + let (mut runner, blade) = board("Ancestral Blade", ANCESTRAL_BLADE, 0); + + runner.cast(blade).resolve(); + runner.advance_until_stack_empty(); + + let host = host_of(&runner, blade).expect("Ancestral Blade must equip its token"); + let host_obj = &runner.state().objects[&host]; + // Printed 1/1; the equipped bonus is already applied to `power`, so assert + // the printed value and the token flag instead of the live P/T. + assert!( + host_obj.is_token && host_obj.base_power == Some(1), + "the host must be the 1/1 Soldier token, got name={:?} is_token={} base_power={:?}", + host_obj.name, + host_obj.is_token, + host_obj.base_power + ); +} + +/// The plain-manifest sibling, and the other runtime publish site: Formless +/// Nurturing's "Manifest the top card of your library, then put a +1/+1 counter +/// on **it**" manifests SYNCHRONOUSLY through `morph::manifest_card` — no +/// `WaitingFor` pause, no continuation. +/// +/// Before the fix the anaphor bound to `TargetFilter::SelfRef`, i.e. the +/// SORCERY itself, so the counter went nowhere. Fierce Invocation and Wildcall +/// print the same line with a different count. +const FORMLESS_NURTURING: &str = + "Manifest the top card of your library, then put a +1/+1 counter on it."; + +#[test] +fn a_plain_manifest_puts_its_counter_on_the_manifested_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_card_to_library_top(P0, "Library Top"); + let spell = scenario + .add_spell_to_hand(P0, "Formless Nurturing", false) + .from_oracle_text(FORMLESS_NURTURING) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool(P0, vec![]); + let mut runner = scenario.build(); + let manifested = runner.state().players[0].library[0]; + + runner.cast(spell).resolve(); + runner.advance_until_stack_empty(); + + assert!( + runner.state().objects[&manifested].face_down, + "the top card must be manifested face down" + ); + assert_eq!( + runner.state().objects[&manifested] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0), + 1, + "the +1/+1 counter belongs on the manifested creature, not on the sorcery" + ); +} + +/// The bare object pronoun is NOT re-routed. "…then attach this Equipment to +/// **it**" keeps the binding its own subject-aware authority +/// (`attach_neuter_recipient_resolves_via_subject`) gives it, which for this +/// shape is `SelfRef` — Cryptic Coat prints exactly this line after a cloak. +/// +/// The demonstrative-only entry point is what makes that true: routing bare "it" +/// through the chain-created binding as well would change attachment for chains +/// that have nothing to do with a chain-created referent. +#[test] +fn the_bare_pronoun_recipient_is_left_to_its_own_authority() { + let parsed = engine::parser::parse_oracle_text( + "When this Equipment enters, manifest dread, then attach this Equipment to it.", + "Bare Pronoun Coat", + &[], + &["Artifact".to_string()], + &["Equipment".to_string()], + ); + let attach = parsed.triggers[0] + .execute + .as_deref() + .and_then(|execute| execute.sub_ability.as_deref()) + .expect("the attach clause is the producer's continuation"); + assert!( + matches!( + &*attach.effect, + engine::types::ability::Effect::Attach { + target: engine::types::ability::TargetFilter::SelfRef, + .. + } + ), + "bare \"it\" must keep its pre-existing binding, got {:?}", + attach.effect + ); +} + +/// CR 603.12 + CR 608.2c: a face-down producer under an AFFIRMATIVE reflexive +/// gate seeds the referent, and its consumer must stay inside the gated +/// instruction — carrying the same condition — rather than becoming an +/// independent sibling that reads the game-lifetime `last_created_token_ids` +/// ledger when the gate is false. +/// +/// This is the shape the widened producer predicate has to cover: seeding +/// (`chain_prior_referent_is_created_token`), gated relinking +/// (`relink_gated_token_referent_consumers`) and clone transplanting +/// (`clone_would_transplant_gated_referent`) now ask one question, so a gated +/// manifest cannot seed a referent that the relink pass then declines to protect. +#[test] +fn a_gated_face_down_producer_keeps_its_consumer_under_the_gate() { + let parsed = engine::parser::parse_oracle_text( + "When this Equipment enters, you may pay {1}. If you do, manifest dread, then attach this Equipment to that creature.", + "Gated Machete", + &[], + &["Artifact".to_string()], + &["Equipment".to_string()], + ); + let producer = parsed.triggers[0] + .execute + .as_deref() + .and_then(|execute| execute.sub_ability.as_deref()) + .expect("the gated manifest dread is the payment's continuation"); + assert!( + producer.condition.is_some(), + "the producer must carry the reflexive gate, got {:?}", + producer.condition + ); + let attach = producer + .sub_ability + .as_deref() + .expect("the attach clause must sit INSIDE the gated instruction"); + assert!( + matches!( + &*attach.effect, + engine::types::ability::Effect::Attach { + target: engine::types::ability::TargetFilter::LastCreated, + .. + } + ), + "the gated producer's consumer binds the chain-created referent, got {:?}", + attach.effect + ); + assert_eq!( + attach.condition, producer.condition, + "and it cannot resolve when the gate is false" + ); +}