From 45ba0609ccff460dbf6007c10b572c63509e6a68 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:06:03 +0200 Subject: [PATCH 1/5] fix(engine,parser): bind a post-manifest anaphor to the manifested creature (#7531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 608.2c + CR 701.40a + CR 701.58a + CR 701.62a. A clause that puts a face-down permanent onto the battlefield produces exactly one new permanent and declares no target, so a following "it" / "that creature" has exactly one possible referent — the same situation as a token producer. The chain-referent machinery only recognised TOKEN producers, so the anaphor fell through: Conductive Machete "manifest dread, then attach this Equipment to that creature" -> Attach { ParentTarget }, and ManifestDread has no targets, so the attach was a silent no-op. Formless Nurturing "Manifest the top card of your library, then put a +1/+1 counter on it" -> PutCounter { SelfRef }, i.e. the SORCERY, so the counter went nowhere. Three parts, all on existing authorities: * `publishes_chain_created_referent` (lower.rs) widens the chain-referent predicate from "created a token" to "produced a permanent", adding Manifest, ManifestDread and Cloak next to Populate/Token/CopyTokenOf. * `parse_attach_recipient` (imperative.rs) routes a DEMONSTRATIVE recipient through `counter_anaphor_created_token_binding` — the authority the counter path already uses for the identical anaphor — so the two consumers cannot disagree about what "that creature" means. The bare "it" branch keeps its own wider gate untouched. * `morph::publish_face_down_entry_referent` records the face-down entrant in the same referent slot the token producer writes. `manifest_card` is the one runtime producer for manifest / manifest dread / cloak; manifest dread's two-card arm moves the chosen card from its own continuation, so it publishes there too. Class, measured by parsing all 35,399 distinct cards in `client/public/card-data.json` with and without the change: exactly 7 cards change parse, and every change is a fix. | card | before | after | |---|---|---| | Conductive Machete | Attach { ParentTarget } | Attach { LastCreated } | | Cursed Windbreaker | Attach { ParentTarget } | Attach { LastCreated } | | Dissection Tools | Attach { ParentTarget } | Attach { LastCreated } | | Killer's Mask | Attach { ParentTarget } | Attach { LastCreated } | | Fierce Invocation | PutCounter { SelfRef } | PutCounter { LastCreated } | | Formless Nurturing | PutCounter { SelfRef } | PutCounter { LastCreated } | | Wildcall | PutCounter { SelfRef } | PutCounter { LastCreated } | Counter-probe: with the demonstrative branch removed, `the_equipment_attaches_to_the_creature_manifest_dread_produced` fails with `attached_to: None` while the token control test stays green. Not covered: * Cryptic Coat ("cloak the top card of your library, then attach this Equipment to **it**") still lowers to `Attach { SelfRef }`. The bare object pronoun runs through `attach_neuter_recipient_resolves_via_subject`, a deliberately wider gate this change does not touch. * Weight Room, Slimy Aquarium and Experimental Lab ("When you unlock this door, manifest dread, then put N +1/+1 counters on that creature") still bind `TriggeringSource` — the counter path's trigger-subject gate re-anchors the demonstrative to the Room, which can never be the creature. That gate is a separate question with its own blast radius. * Valgavoth's Onslaught's PLURAL anaphor ("each of those creatures") reads the chain tracked set, not this single-referent slot — the `ManifestDread` row of #7467. Publishing that set is measured NOT to be sufficient on its own: the ability's `repeat_for X` wraps the follow-up counter clause, so with X = 2 the first manifested creature ends up with 4 counters instead of 2. Reported separately rather than half-fixed here. * Goblin Plate Mail ("amass Goblins 1, then attach this Equipment to the amassed Army") lowers to `Attach { Any }` — a different anaphor with its own producer. Co-Authored-By: Claude Opus 5 --- .../src/game/engine_resolution_choices.rs | 10 +- crates/engine/src/game/morph.rs | 28 ++- .../src/parser/oracle_effect/imperative.rs | 25 +++ .../engine/src/parser/oracle_effect/lower.rs | 23 +++ crates/engine/src/parser/oracle_effect/mod.rs | 4 +- crates/engine/tests/integration/main.rs | 1 + .../manifest_dread_that_creature_anaphor.rs | 195 ++++++++++++++++++ 7 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index aa677b3b58..c48065aca8 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1833,7 +1833,15 @@ pub(super) fn handle_resolution_choice( .face_down(face_down), events, ) { - crate::game::zone_pipeline::ZoneMoveResult::Done => {} + crate::game::zone_pipeline::ZoneMoveResult::Done => { + // CR 608.2c: the manifested card is the chain's most-recent + // created referent, so a following "that creature" anaphor + // binds to it (Conductive Machete, #7531). Manifest dread's + // count == 1 arm publishes through `morph::manifest_card`; + // this two-card arm moves the chosen card itself, so it + // publishes through the same helper here. + crate::game::morph::publish_face_down_entry_referent(state, manifest_id); + } // CR 303.4f / CR 616.1 + CR 701.62a: the chosen card's manifest // entry paused (aura host pick or a replacement-ordering prompt). // Defer the non-manifested card's graveyard move + reveal-marker diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index dfa4f393ce..2a89f4c07e 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -554,12 +554,38 @@ pub fn manifest_card( request = request.under_control_of(controller); } match super::zone_pipeline::move_object(state, request, events) { - super::zone_pipeline::ZoneMoveResult::Done => Ok(()), + super::zone_pipeline::ZoneMoveResult::Done => { + publish_face_down_entry_referent(state, object_id); + Ok(()) + } + // CR 616.1: the entry parked. The object has not entered yet, so it is + // not the chain's referent — the resume path is where a publish would + // belong, and it is not wired (see `publish_face_down_entry_referent`). 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]; +} + /// 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/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 72693dc966..a0f634a169 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -6038,6 +6038,31 @@ 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 + // bare "it" form is handled by the branch above and is left untouched: + // its gate is deliberately wider + // (`attach_neuter_recipient_resolves_via_subject`) and re-routing it + // here would change bindings unrelated to a chain-created referent. + if let Some(bound) = + super::counter::counter_anaphor_created_token_binding(lower.trim(), ctx) + { + return (bound, &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 + // bare "it" form is handled by the branch above and is left untouched: + // its gate is deliberately wider (`attach_neuter_recipient_resolves_via_subject`) + // and re-routing it here would change bindings that have nothing to do + // with a chain-created referent. } (target, rest) } diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index a3e84ac7f0..5ad807ddc1 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. /// 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/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..68a162e7b6 --- /dev/null +++ b/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs @@ -0,0 +1,195 @@ +//! 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" + ); +} + +/// 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" + ); +} From d8d15d91970a9c799385fa9b80e4f554c527a4a1 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:03:28 +0200 Subject: [PATCH 2/5] fix(PR-7533): one producer predicate, one publish site, demonstratives only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three review findings on `afbbc34c2`. 1. `relink_gated_token_referent_consumers` and `clone_would_transplant_gated_referent` searched publishers with `is_token_creating_effect` while the seeder used the wider `publishes_chain_created_referent`. A gated Manifest/ManifestDread/Cloak could therefore seed `LastCreated` and still leave its consumer a `SequentialSibling` that reads the game-lifetime ledger when the gate is false. All three passes now ask the one predicate. 2. The referent publish moves out of `morph::manifest_card` and the manifest dread continuation into `zone_pipeline::apply_face_down_entry_profile` — the single helper the synchronous entry, the two-card continuation AND the CR 616.1 parked-entry resume all run. It is gated on the object's ZONE rather than on the caller, because `casting.rs` runs the same helper for a face-down CAST, where the object is on the stack and has produced no permanent to name. 3. The attach recipient now calls a DEMONSTRATIVE-only entry point (`chain_created_demonstrative_binding`) instead of the composed counter binding, so bare "it" keeps the binding its own subject-aware authority gives it. `counter_anaphor_created_token_binding` composes the same helper for the counter path, so the two forms still cannot disagree about "that creature". Regressions added: * `a_battlefield_face_down_entry_publishes_the_chain_referent` / `a_face_down_cast_on_the_stack_publishes_nothing` — the zone gate, in both directions. * `a_gated_face_down_producer_keeps_its_consumer_under_the_gate` — a gated manifest dread's attach stays inside the gated instruction and carries the same condition. * `the_bare_pronoun_recipient_is_left_to_its_own_authority` — "…then attach this Equipment to it" is unchanged (`SelfRef`, the Cryptic Coat shape). Still not proven: no test drives a real CR 616.1 entry pause end to end. The parked arm is covered by sharing the publish line with the synchronous arm, and the zone-gate tests pin which callers publish; the two-card continuation is covered end to end by the existing integration test. Co-Authored-By: Claude Opus 5 --- .../src/game/engine_resolution_choices.rs | 10 +-- crates/engine/src/game/morph.rs | 11 +-- crates/engine/src/game/zone_pipeline.rs | 89 +++++++++++++++++++ .../src/parser/oracle_effect/counter.rs | 43 ++++++++- .../src/parser/oracle_effect/imperative.rs | 12 +-- .../engine/src/parser/oracle_effect/lower.rs | 12 ++- .../manifest_dread_that_creature_anaphor.rs | 86 ++++++++++++++++++ 7 files changed, 235 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index c48065aca8..aa677b3b58 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1833,15 +1833,7 @@ pub(super) fn handle_resolution_choice( .face_down(face_down), events, ) { - crate::game::zone_pipeline::ZoneMoveResult::Done => { - // CR 608.2c: the manifested card is the chain's most-recent - // created referent, so a following "that creature" anaphor - // binds to it (Conductive Machete, #7531). Manifest dread's - // count == 1 arm publishes through `morph::manifest_card`; - // this two-card arm moves the chosen card itself, so it - // publishes through the same helper here. - crate::game::morph::publish_face_down_entry_referent(state, manifest_id); - } + crate::game::zone_pipeline::ZoneMoveResult::Done => {} // CR 303.4f / CR 616.1 + CR 701.62a: the chosen card's manifest // entry paused (aura host pick or a replacement-ordering prompt). // Defer the non-manifested card's graveyard move + reveal-marker diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index 2a89f4c07e..f6c07e2f11 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -554,13 +554,10 @@ pub fn manifest_card( request = request.under_control_of(controller); } match super::zone_pipeline::move_object(state, request, events) { - super::zone_pipeline::ZoneMoveResult::Done => { - publish_face_down_entry_referent(state, object_id); - Ok(()) - } - // CR 616.1: the entry parked. The object has not entered yet, so it is - // not the chain's referent — the resume path is where a publish would - // belong, and it is not wired (see `publish_face_down_entry_referent`). + // 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(()), } diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 05b4160e36..97446509a4 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -3193,6 +3193,29 @@ pub(crate) fn apply_face_down_entry_profile( obj.face_down_cause = Some(profile.cause); obj.back_face = Some(original); } + + // CR 608.2c: a permanent that just entered the battlefield face down 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). + // + // Published HERE, in the single helper every face-down entry runs through, + // rather than at the producing effects: manifest's synchronous arm, manifest + // dread's two-card continuation and the CR 616.1 parked-entry resume all + // reach this one line, so a paused entry cannot resume with the referent + // unpublished (or with a stale one from an earlier resolution). + // + // Gated on the object's zone, not on the caller: `casting.rs` runs the same + // helper for a face-down CAST, where the object is on the stack and has + // produced no permanent to name yet. That is a state question with a state + // answer, not a classification of the call site. + if state + .objects + .get(&object_id) + .is_some_and(|obj| obj.zone == crate::types::zones::Zone::Battlefield) + { + crate::game::morph::publish_face_down_entry_referent(state, object_id); + } } /// CR 730.3e (second clause) + CR 730.2d + CR 614.6: compute the card-component @@ -6256,3 +6279,69 @@ 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 chain-created referent is published by the ONE helper every + /// face-down entry runs through — the synchronous manifest, manifest dread's + /// two-card continuation and the CR 616.1 parked-entry resume all reach this + /// line, so none of them can leave the referent unpublished (#7531). + /// + /// What this test proves is the ZONE gate, which is the part that decides + /// which callers publish. It does NOT drive a real CR 616.1 pause end to end; + /// the manifest-dread integration test covers the continuation arm, and the + /// parked arm is covered only by sharing this line. + #[test] + fn a_battlefield_face_down_entry_publishes_the_chain_referent() { + let mut state = GameState::new_two_player(7); + let player = PlayerId(0); + let id = create_object( + &mut state, + CardId(1), + player, + "Manifested".to_string(), + Zone::Battlefield, + ); + state.last_created_token_ids = vec![ObjectId(999)]; + + apply_face_down_entry_profile(&mut state, id, &FaceDownProfile::vanilla_2_2()); + + assert_eq!( + state.last_created_token_ids, + vec![id], + "a face-down permanent is the chain's most-recent created referent" + ); + } + + /// The counter-direction that makes the gate load-bearing: `casting.rs` runs + /// the same helper for a face-down CAST, where the object is on the STACK and + /// has produced no permanent to name. Publishing there would let a later + /// `LastCreated` bind a spell. + #[test] + fn a_face_down_cast_on_the_stack_publishes_nothing() { + let mut state = GameState::new_two_player(7); + let player = PlayerId(0); + let id = create_object( + &mut state, + CardId(1), + player, + "Morph Spell".to_string(), + Zone::Stack, + ); + 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, + "a face-down spell on the stack is not a created permanent" + ); + } +} 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 a0f634a169..5b5dcc0d54 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -6044,12 +6044,12 @@ fn parse_attach_recipient<'a>(text: &'a str, ctx: &mut ParseContext) -> (TargetF // 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 - // bare "it" form is handled by the branch above and is left untouched: - // its gate is deliberately wider - // (`attach_neuter_recipient_resolves_via_subject`) and re-routing it - // here would change bindings unrelated to a chain-created referent. - if let Some(bound) = - super::counter::counter_anaphor_created_token_binding(lower.trim(), ctx) + // 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()..]); } diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 5ad807ddc1..912ab9920b 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2693,11 +2693,19 @@ pub(super) fn publishes_chain_created_referent(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; }; @@ -2963,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/tests/integration/manifest_dread_that_creature_anaphor.rs b/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs index 68a162e7b6..cb25bdaa75 100644 --- a/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs +++ b/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs @@ -193,3 +193,89 @@ fn a_plain_manifest_puts_its_counter_on_the_manifested_creature() { "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" + ); +} From 90a1957edda394d702610b4557dcc64b38698ebc Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:20:21 +0200 Subject: [PATCH 3/5] fix(PR-7533): carry the producer intent on the entry request (#7531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish moved off the shared CR 708.3 characteristics helper and onto the entry request, where the question can actually be answered. The old placement asked the ENTRANT ("is it on the battlefield?"). That cannot establish that this entry was the producer the sentence refers back to: `apply_face_down_entry_profile` is reached by the ordinary face-down special action, by the generic `ZoneMoveRequest::face_down` delivery and by two cast SIMULATIONS in `casting.rs`, while the parser admits only manifest, manifest dread and cloak as chain-referent producers. A zone check would let any of the others overwrite the game-lifetime `LastCreated` ledger. `ChainReferentIntent { Silent, Publishes }` now rides `EntryMods` -> `PendingBatchZoneMoveRequest` -> `ProposedEvent::ZoneChange` -> delivery, so it survives a CR 616.1 park/resume with the rest of the request, and is consumed only once the entry has settled (`entered_battlefield`) — a `CantEnterBattlefieldFrom` rejection publishes nothing. `Silent` is the default, so a face-down delivery added later cannot opt in by accident. The mark sits on the producer, not on a list: manifest (CR 701.40a), manifest dread (CR 701.62a) and cloak (CR 701.58a) all reach the battlefield through `morph::manifest_card`'s one request, plus manifest dread's two-card continuation in `engine_resolution_choices`. Those are exactly the three the parser's `publishes_chain_created_referent` admits. The shared helper now installs characteristics and nothing else, which is what makes it safe for every other caller. Its unit row asserts that negative from both zones. ## The stale-referent half Writing the reviewer's requested test surfaced a second defect the first head also had: `LastCreated` is a game-lifetime slot, so manifest dread on an EMPTY library left the PREVIOUS instruction's referent standing and "that creature" reached back to it — the Machete equipped a creature the sentence never mentioned. A producer now clears the slot up front (`morph::begin_face_down_referent_production`, called by all three resolvers), and a successful delivery publishes. Split in two because the manifest family cannot assign at its tail the way the token producer does: a CR 616.1 entry pause can park the delivery past the end of the resolver. Net semantics are the token producer's — produced something: that id; produced nothing: empty. ## Coverage Two production rows, both starting with a REAL prior referent (an Ancestral Blade cast and resolved, leaving its Soldier token in the slot), so they can tell "published the new entrant" from "retained what was there": * accepted: the paused two-card choice is answered, and the Machete equips what its OWN manifest dread produced, never the earlier token. * nothing produced: the Machete stays unattached and the slot is empty. Counter-probe: | disabled | failing rows | |---|---| | the intent gate | plain manifest, the Machete row, the paused continuation | | the producer's up-front clear | `a_producer_that_produces_nothing_does_not_leave_a_prior_referent_standing`, on `ObjectId(4) from the previous instruction` | Also removed the duplicated explanatory comment in `imperative.rs` and repaired the "The The" typo in the surviving copy. Not covered: the four public `execute_zone_move*` wrappers pass `Silent`. They are raw movers with no originating instruction to speak for; a caller that needs to publish goes through `ZoneMoveRequest`. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/cloak.rs | 4 + crates/engine/src/game/effects/discard.rs | 1 + crates/engine/src/game/effects/manifest.rs | 4 + .../engine/src/game/effects/manifest_dread.rs | 4 + crates/engine/src/game/elimination.rs | 2 + crates/engine/src/game/engine_debug.rs | 1 + crates/engine/src/game/engine_replacement.rs | 5 + .../src/game/engine_resolution_choices.rs | 5 +- crates/engine/src/game/morph.rs | 26 ++- crates/engine/src/game/replacement.rs | 6 + crates/engine/src/game/zone_pipeline.rs | 155 +++++++++--------- .../src/parser/oracle_effect/imperative.rs | 13 +- crates/engine/src/types/game_state.rs | 7 +- crates/engine/src/types/proposed_event.rs | 8 +- crates/engine/src/types/zones.rs | 42 +++++ .../tests/integration/integration_bending.rs | 2 + .../manifest_dread_that_creature_anaphor.rs | 110 +++++++++++++ 17 files changed, 303 insertions(+), 92 deletions(-) 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 f6c07e2f11..8168e097a6 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -547,9 +547,16 @@ 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); } @@ -583,6 +590,23 @@ pub(crate) fn publish_face_down_entry_referent(state: &mut GameState, object_id: 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 97446509a4..0795e5ad9d 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 { @@ -1007,6 +1020,7 @@ pub(crate) fn move_object_with_terminal( controller_override, enter_with_counters, face_down_profile, + chain_referent, applied, .. } = &mut proposed @@ -1019,6 +1033,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 +1075,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 +1455,7 @@ fn anticipated_zone_change_delivery( controller_override, enter_with_counters, face_down_profile, + chain_referent, attach_to, applied, .. @@ -1450,6 +1467,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(); } @@ -3193,29 +3211,6 @@ pub(crate) fn apply_face_down_entry_profile( obj.face_down_cause = Some(profile.cause); obj.back_face = Some(original); } - - // CR 608.2c: a permanent that just entered the battlefield face down 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). - // - // Published HERE, in the single helper every face-down entry runs through, - // rather than at the producing effects: manifest's synchronous arm, manifest - // dread's two-card continuation and the CR 616.1 parked-entry resume all - // reach this one line, so a paused entry cannot resume with the referent - // unpublished (or with a stale one from an earlier resolution). - // - // Gated on the object's zone, not on the caller: `casting.rs` runs the same - // helper for a face-down CAST, where the object is on the stack and has - // produced no permanent to name yet. That is a state question with a state - // answer, not a classification of the call site. - if state - .objects - .get(&object_id) - .is_some_and(|obj| obj.zone == crate::types::zones::Zone::Battlefield) - { - crate::game::morph::publish_face_down_entry_referent(state, object_id); - } } /// CR 730.3e (second clause) + CR 730.2d + CR 614.6: compute the card-component @@ -3346,6 +3341,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, @@ -3715,6 +3711,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 @@ -4110,6 +4124,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, @@ -4133,6 +4148,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, @@ -4160,8 +4180,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 @@ -6288,60 +6314,35 @@ mod face_down_entry_referent_tests { use crate::types::identifiers::CardId; use crate::types::player::PlayerId; - /// CR 608.2c: the chain-created referent is published by the ONE helper every - /// face-down entry runs through — the synchronous manifest, manifest dread's - /// two-card continuation and the CR 616.1 parked-entry resume all reach this - /// line, so none of them can leave the referent unpublished (#7531). + /// 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. /// - /// What this test proves is the ZONE gate, which is the part that decides - /// which callers publish. It does NOT drive a real CR 616.1 pause end to end; - /// the manifest-dread integration test covers the continuation arm, and the - /// parked arm is covered only by sharing this line. - #[test] - fn a_battlefield_face_down_entry_publishes_the_chain_referent() { - let mut state = GameState::new_two_player(7); - let player = PlayerId(0); - let id = create_object( - &mut state, - CardId(1), - player, - "Manifested".to_string(), - Zone::Battlefield, - ); - state.last_created_token_ids = vec![ObjectId(999)]; - - apply_face_down_entry_profile(&mut state, id, &FaceDownProfile::vanilla_2_2()); - - assert_eq!( - state.last_created_token_ids, - vec![id], - "a face-down permanent is the chain's most-recent created referent" - ); - } - - /// The counter-direction that makes the gate load-bearing: `casting.rs` runs - /// the same helper for a face-down CAST, where the object is on the STACK and - /// has produced no permanent to name. Publishing there would let a later - /// `LastCreated` bind a spell. + /// 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 a_face_down_cast_on_the_stack_publishes_nothing() { - let mut state = GameState::new_two_player(7); - let player = PlayerId(0); - let id = create_object( - &mut state, - CardId(1), - player, - "Morph Spell".to_string(), - Zone::Stack, - ); - 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, - "a face-down spell on the stack is not a created permanent" - ); + 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/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 5b5dcc0d54..7e631d65dd 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -6043,7 +6043,8 @@ fn parse_attach_recipient<'a>(text: &'a str, ctx: &mut ParseContext) -> (TargetF // 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 + // 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 @@ -6053,16 +6054,6 @@ fn parse_attach_recipient<'a>(text: &'a str, ctx: &mut ParseContext) -> (TargetF { return (bound, &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 - // bare "it" form is handled by the branch above and is left untouched: - // its gate is deliberately wider (`attach_neuter_recipient_resolves_via_subject`) - // and re-routing it here would change bindings that have nothing to do - // with a chain-created referent. } (target, rest) } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e7f975f92b..7a789b0430 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,11 @@ pub struct PendingBatchZoneMoveRequest { pub enter_with_counters: Vec<(CounterType, u32)>, #[serde(default, skip_serializing_if = "Option::is_none")] pub face_down_profile: Option, + /// CR 608.2c: whether this parked entry is the producer a following + /// demonstrative anaphor refers back to. Parked with the rest of the + /// request so a CR 616.1 pause cannot lose it. + #[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/manifest_dread_that_creature_anaphor.rs b/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs index cb25bdaa75..de5f77c860 100644 --- a/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs +++ b/crates/engine/tests/integration/manifest_dread_that_creature_anaphor.rs @@ -128,6 +128,116 @@ fn an_empty_library_manifests_nothing_and_attaches_nothing() { ); } +/// 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. From 9b510601aa541a96354ba6adc1af820ad4d0f536 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 15:06:52 -0700 Subject: [PATCH 4/5] fix(PR-7533): preserve chain referent through zone requests --- crates/engine/src/game/zone_pipeline.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 0795e5ad9d..3faaa28845 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -852,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) { @@ -926,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); } From 2298b97cdfa833a50f125c15d7c4675276260297 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 17:02:49 -0700 Subject: [PATCH 5/5] fix(PR-7533): remove unrelated CR citations --- crates/engine/src/types/game_state.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 7a789b0430..2d291eecd6 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5074,9 +5074,8 @@ pub struct PendingBatchZoneMoveRequest { pub enter_with_counters: Vec<(CounterType, u32)>, #[serde(default, skip_serializing_if = "Option::is_none")] pub face_down_profile: Option, - /// CR 608.2c: whether this parked entry is the producer a following - /// demonstrative anaphor refers back to. Parked with the rest of the - /// request so a CR 616.1 pause cannot lose it. + /// 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")]