diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 1286b62186..2dd0b16eb7 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -38,10 +38,11 @@ use super::lower::{ attach_cast_cost_raise_to_previous_play_from_exile, attach_graveyard_redirect_rider_to_prior_cast_from_zone, attach_land_enters_tapped_to_previous_play_from_exile, cast_cost_raise_rider, - consolidate_die_and_coin_defs, definition_targets_self_source, - effect_publishes_revealed_subject, extract_bounded_target_multi_target, - extract_exact_target_multi_target, extract_optional_target_multi_target, - extract_verb_up_to_multi_target, fold_copy_spell_gains_haste_and_quoted_grant, + clone_would_transplant_gated_referent, consolidate_die_and_coin_defs, + definition_targets_self_source, effect_publishes_revealed_subject, + extract_bounded_target_multi_target, extract_exact_target_multi_target, + extract_optional_target_multi_target, extract_verb_up_to_multi_target, + fold_copy_spell_gains_haste_and_quoted_grant, fold_deal_damage_then_prevent_into_computed_amount, fold_enters_this_way_counter_rider, fold_exile_resolving_rider, fold_search_choose_type_conditional_destination, fold_token_it_has_grants_into_token_statics, gate_other_revealed_card_on_multiplayer_reveal, @@ -53,13 +54,13 @@ use super::lower::{ parse_controlled_by_different_players_target_constraint, parse_same_zone_owner_target_constraint, parse_total_mana_value_target_constraint, patch_choose_from_zone_counter_continuation_target, patch_population_head_tap_anaphor, - patch_self_ref_head_tap_anaphor, resolve_populated_token_anaphors, - resolve_populated_unsuspect_anaphors, resolve_those_tokens_anaphors, - rewire_result_anchored_subchain, rewrite_counter_instead_target_from_antecedent, - rewrite_else_event_context_to_stable, rewrite_else_parent_target_to_self_ref, - rewrite_player_anaphor_targets_in_definition, rewrite_those_tokens_from_antecedent, - rewrite_two_target_counter_chain, target_choice_timing_for_clause, - thread_chosen_damage_source_into_oneshot_effects, + patch_self_ref_head_tap_anaphor, relink_gated_token_referent_consumers, + resolve_populated_token_anaphors, resolve_populated_unsuspect_anaphors, + resolve_those_tokens_anaphors, rewire_result_anchored_subchain, + rewrite_counter_instead_target_from_antecedent, rewrite_else_event_context_to_stable, + rewrite_else_parent_target_to_self_ref, rewrite_player_anaphor_targets_in_definition, + rewrite_those_tokens_from_antecedent, rewrite_two_target_counter_chain, + target_choice_timing_for_clause, thread_chosen_damage_source_into_oneshot_effects, }; use super::sequence::{apply_clause_continuation, def_bears_retargetable_copy}; use super::{ @@ -1375,14 +1376,32 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { } } ReplicateKind::CounterPlacement => { - let bound = env.resolve( - &defs, - AntecedentSelector::LastWithRole( - AntecedentRole::KeywordCounterPlacement, - ), - None, - OnMiss::Ignore, - ); + let bound = env + .resolve( + &defs, + AntecedentSelector::LastWithRole( + AntecedentRole::KeywordCounterPlacement, + ), + None, + OnMiss::Ignore, + ) + // CR 603.12: the clones are pushed at the TAIL of + // `defs` carrying the template's target VERBATIM, so + // a template that reads a gated publisher's + // `TargetFilter::LastCreated` would have that + // chain-context referent transplanted past whatever + // sits in between, where + // `state.last_created_token_ids`, a game-lifetime + // ledger, binds a token from an EARLIER resolution. + // Decline ONLY that shape: the predicate builds the + // clone and asks + // `lower::relink_gated_token_referent_consumers` + // itself whether it lands honestly. Every other + // binding is replicated as printed — CR 608.2c has + // the controller follow the instructions in the + // order WRITTEN, so silently dropping a printed + // replication is the worse error direction. + .filter(|i| !clone_would_transplant_gated_referent(&defs, *i)); if let Some(bound_index) = bound { attach_repeat_process_keywords(&mut defs, bound_index, keywords); } @@ -1821,16 +1840,10 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { def.description = Some(clause_ir.source.fragment().unwrap_or_default().to_string()); } // CR 608.2c: This clause's link to its parent = the boundary that - // SEPARATED the previous clause from this one. A `Sentence` boundary - // marks a `SequentialSibling` (next printed instruction, resolves even - // when an optional parent is declined); `Comma`/`Then`/none marks a - // within-clause `ContinuationStep` (part of the parent's action). - def.sub_link = match prev_boundary { - Some(ClauseBoundary::Sentence) => SubAbilityLink::SequentialSibling, - Some(ClauseBoundary::Then) | Some(ClauseBoundary::Comma) | None => { - SubAbilityLink::ContinuationStep - } - }; + // SEPARATED the previous clause from this one, translated by the single + // boundary→link authority (`oracle_ir::ast::sub_link_after_boundary`), + // which the referent walk in `oracle_effect::mod` also consults. + def.sub_link = sub_link_after_boundary(prev_boundary); // CR 615.5: A "(When|Whenever|If) damage [from a source] is // prevented this way, …" rider is printed as its own sentence but is not // an independent instruction — its "this way" back-reference binds to the @@ -2487,6 +2500,14 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // creation for the Sacrifice case — CR 603.7c). resolve_populated_token_anaphors(&mut defs); + // CR 603.12 + CR 609.3: A clause whose subject is the token published by a + // clause under an affirmative reflexive gate ("When you do, create a token. + // Put a +1/+1 counter on that token.") is part of that gated instruction, + // not the next independent one — with no token created it can do nothing. + // Must run AFTER the anaphor rewrites above, which are what bind the + // referent it looks for. + relink_gated_token_referent_consumers(&mut defs); + // CR 707.12: "Copy [a card]. You may cast the copy ..." is not a stack // copy (CR 707.10). It creates a card copy in the source zone, then casts // that copy during resolution. Fold the two parsed imperative clauses into diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 0c03403dc0..513d7aa6a3 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2517,6 +2517,332 @@ pub(super) fn is_token_creating_effect(effect: &Effect) -> bool { ) } +/// 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. +/// +/// A reflexive gate ("When you do", "If you do") means the antecedent may not +/// have happened, in which case its clause created no token. A following clause +/// whose only subject is that token ("Put a +1/+1 counter on that token") is +/// then not the next independent instruction (CR 608.2c) — it is an instruction +/// that can do nothing at all (CR 609.3: an effect does only as much as +/// possible). Tagging it `SequentialSibling` makes the resolver's +/// condition-false descent resolve it anyway, and `TargetFilter::LastCreated` +/// resolves against `state.last_created_token_ids`, a GAME-LIFETIME ledger that +/// is never cleared at a resolution boundary — so it would bind a token from an +/// EARLIER resolution. +/// +/// Re-linking to `ContinuationStep` makes the clause a resolution step of the +/// instruction it is already attached to — and `gated_instruction_reaches` +/// restricts the pass to the case where that instruction is the gated +/// publisher's own, which is what the printed text means: it resolves when the +/// gate is true and is skipped when it is false. Because the whole clause moves, +/// every referent-reading position inside it moves with it — there is no +/// per-effect read-position list to keep in sync. +/// +/// Deliberately narrow: only a clause that reads the referent the gated clause +/// PUBLISHES, and that is not separated from it by an independent instruction, +/// is re-linked. A genuinely independent tail after a reflexive gate still +/// resolves (Springheart Nantuko's "If you didn't create a token this way" +/// complement; Scion of the Ur-Dragon's "Then shuffle", CR 701.23 + CR 701.24; +/// Localized Destruction's "Destroy all creatures"). +/// +/// This pass and the referent walk that seeds the binding +/// (`parser::oracle_effect::chain_prior_referent_is_created_token`) are two +/// halves of one rule and must not diverge: the walk predicts THIS pass's +/// acceptance before assembly, from the same two authorities — +/// `oracle_ir::ast::sub_link_after_boundary` and +/// [`instruction_spine_is_continuation`] — so a seed cannot land on a shape this +/// pass declines for a reason either authority can see. Widening either half +/// 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`]. +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)) + else { + continue; + }; + if !defs[publisher] + .condition + .as_ref() + .is_some_and(AbilityCondition::is_affirmative_reflexive_gate) + { + continue; + } + if !gated_instruction_reaches(&defs[publisher..i]) { + continue; + } + if defs[i].sub_link == SubAbilityLink::SequentialSibling + && ability_reads_last_created(&defs[i]) + { + defs[i].sub_link = SubAbilityLink::ContinuationStep; + } + } +} + +/// CR 608.2c: Is the clause following `slice` still inside the gated +/// publisher's own instruction? +/// +/// `slice[0]` is the gated publisher; the remaining entries are the clauses +/// between it and the candidate consumer. `sub_link` describes the link to the +/// IMMEDIATELY preceding node, not to the publisher, and the resolver's +/// condition-false descent (`game::effects::resolve_ability_chain`) walks +/// `sub_ability` from the gated node and resolves the FIRST node whose +/// `sub_link` is `SequentialSibling` — together with that node's entire +/// sub-chain. So re-tagging a consumer that sits behind an intervening +/// `SequentialSibling` would only make it a continuation step of THAT sibling: +/// the descent selects the sibling and resolves the consumer anyway, changing +/// the link for nothing. Requiring an unbroken continuation path is what makes +/// the re-tag mean what `SubAbilityLink::ContinuationStep` says it means. +/// +/// Each node's own within-clause spine is checked too, via +/// [`instruction_spine_is_continuation`], because the chain assembler appends the +/// next clause to the DEEPEST `sub_ability`, so an internal `SequentialSibling` +/// rider also sits on the descent path. +fn gated_instruction_reaches(slice: &[AbilityDefinition]) -> bool { + slice.iter().enumerate().all(|(idx, def)| { + (idx == 0 || def.sub_link == SubAbilityLink::ContinuationStep) + && instruction_spine_is_continuation(def) + }) +} + +/// CR 608.2c: Is every node of this definition's own within-clause spine a +/// `ContinuationStep`? +/// +/// Shared by the two passes that must agree on "an unbroken continuation path +/// runs from the gated publisher to the consumer": `gated_instruction_reaches` +/// (above, over assembled `AbilityDefinition`s) and the referent walk +/// `parser::oracle_effect::chain_prior_referent_is_created_token` (over +/// `ClauseIr::parsed.sub_ability`, which is the same `AbilityDefinition` spine +/// before assembly appends the following clause to its deepest node). +/// +/// Not vacuous even though no shipped card exercises it today: PARSE-TIME +/// builders mint an internal `SequentialSibling` rider directly and hand it back +/// inside a `ParsedEffectClause` — [`try_parse_bidirectional_prevent`] here and +/// `oracle_effect::mod::try_parse_exile_play_grant_with_play_prohibition` — so +/// such a spine can reach both callers. +/// +/// SCOPE, stated so the seeder's use of it is not read as a proof: this sees the +/// parse-time spine only. Three ASSEMBLY-time sites mint a `SequentialSibling` +/// that no `ClauseIr` carries and that the referent walk therefore cannot +/// predict. Each would make `gated_instruction_reaches` stricter than the walk +/// predicted, i.e. leave a `LastCreated` bind the re-link does not protect: +/// +/// * [`attach_graveyard_redirect_rider_to_prior_cast_from_zone`] and +/// `absorb_last_created_riders` — each needs an `Effect::CastFromZone` / +/// `Effect::FlipCoins` antecedent, and the second MOVES its rider inside the +/// coin effect, off the top level entirely. +/// * `oracle_effect::mod::attach_repeat_process_keywords`, which pushes cloned +/// TOP-LEVEL siblings rather than a within-clause rider, and clones the +/// template's target VERBATIM. Closed at its binding site: `assembly.rs` +/// declines the binding when [`clone_would_transplant_gated_referent`] holds, +/// and that predicate decides by running THIS pass over the def vector the +/// clone would land in. So every clone that exists is one this pass either +/// re-tagged onto the gated instruction's continuation path or found honest +/// on its own (self-gated, or reading no gated referent at all). +/// +/// The backstop for all three is the invariant "no `SequentialSibling` node +/// reads `TargetFilter::LastCreated`". Two tests carry it, and NEITHER is a +/// corpus sweep — read them for what they cover before relying on them: +/// `bbfu9_no_stale_last_created_bind` asserts it over a FROZEN list of the 20 +/// cards whose AST this change moved, embedded verbatim (it cannot see a card +/// that acquires the shape later), and +/// `repeat_process_directive_never_joins_a_continuation_path` asserts it over +/// the repeat-process grammar's own fixtures. +pub(super) fn instruction_spine_is_continuation(def: &AbilityDefinition) -> bool { + let mut cursor = def.sub_ability.as_deref(); + while let Some(node) = cursor { + if node.sub_link != SubAbilityLink::ContinuationStep { + return false; + } + cursor = node.sub_ability.as_deref(); + } + true +} + +/// CR 111.1: Does this ability (or anything nested inside it) read the +/// just-created-token referent `TargetFilter::LastCreated`? Walks the whole +/// definition — target filter (including composite wrappers), `GenericEffect` +/// grant recipients, a `CreateDelayedTrigger`'s inner definition, modal modes, +/// and the within-clause sub/else chain — so the answer does not depend on an +/// enumeration of which `Effect` variants can carry the referent. +fn ability_reads_last_created(def: &AbilityDefinition) -> bool { + fn filter_reads(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::LastCreated => true, + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(filter_reads) + } + TargetFilter::Not { filter } | TargetFilter::TrackedSetFiltered { filter, .. } => { + filter_reads(filter) + } + TargetFilter::ChosenDamageSource { filter } => { + filter.as_deref().is_some_and(filter_reads) + } + TargetFilter::None + | TargetFilter::Any + | TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::ControllerAndControlledPermanents { .. } + | TargetFilter::Opponent + | TargetFilter::SelfRef + | TargetFilter::GrantingObject + | TargetFilter::SourceOrPaired + | TargetFilter::Typed(..) + | TargetFilter::StackAbility { .. } + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSource + | TargetFilter::EventTarget + | TargetFilter::TriggeringSourceController + | TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::OriginalSource + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageSource + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | TargetFilter::Named { .. } + | TargetFilter::Owner + | TargetFilter::AllPlayers => false, + } + } + if def.effect.target_filter().is_some_and(filter_reads) { + return true; + } + match &*def.effect { + Effect::CreateDelayedTrigger { effect, .. } if ability_reads_last_created(effect) => { + return true; + } + Effect::GenericEffect { + static_abilities, .. + } if static_abilities + .iter() + .any(|s| s.affected.as_ref().is_some_and(filter_reads)) => + { + return true; + } + _ => {} + } + def.sub_ability + .as_deref() + .is_some_and(ability_reads_last_created) + || def + .else_ability + .as_deref() + .is_some_and(ability_reads_last_created) + || def.mode_abilities.iter().any(ability_reads_last_created) +} + +/// CR 603.12: Would replicating `defs[template]` at the TAIL of `defs` +/// transplant a gated publisher's just-created-token referent to a slot the +/// resolver can reach without that token? +/// +/// `oracle_effect::mod::attach_repeat_process_keywords` ("Repeat this process +/// for …") clones its template VERBATIM — target included — and pushes the +/// clones at the end of `defs`. That is position-independent unless the template +/// reads `TargetFilter::LastCreated`, which is a CHAIN-CONTEXT referent: +/// [`relink_gated_token_referent_consumers`] keeps such a read honest only while +/// an unbroken continuation path runs from the gated clause that published it, +/// and a clone landing off that path keeps `SubAbilityLink::SequentialSibling`. +/// The resolver's condition-false descent then resolves the clone anyway, and +/// `state.last_created_token_ids` is a game-lifetime ledger — so on a false gate +/// it binds a token from an EARLIER resolution. +/// +/// Two early returns bound the question, and neither is a guess about position: +/// +/// * a template that reads no `LastCreated` carries no chain-context referent at +/// all — nothing to transplant, wherever the clone lands; +/// * an UNGATED nearest publisher creates its token unconditionally during this +/// resolution, so the read is live at any position — that is BASE behaviour +/// and not a hazard. +/// +/// The remaining question — "is the clone honest where it lands?" — is not +/// re-derived here. It is ASKED, by building the def +/// [`super::attach_repeat_process_keywords`] will push +/// ([`super::repeat_process_clone_shape`], the shared authority for that shape) +/// and running [`relink_gated_token_referent_consumers`] over the result. The +/// clone is honest if either answer comes back yes: +/// +/// * the re-link re-tags it `ContinuationStep`, so it is a resolution step of +/// the gated instruction and the condition-false descent never selects it; or +/// * it is SELF-GATED — [`AbilityDefinition::is_self_gated_reflexive`] — so the +/// descent's own false-condition skip drops it wherever it sits. +/// +/// Running the pass rather than predicting it is what makes the answer exact. +/// The prediction has to model the pass's ORDER (the template is re-tagged +/// before the clone is examined, so a `Sentence`-joined template that is still +/// `SequentialSibling` at this point must be treated as if it were not) and the +/// pass's choice of publisher for the clone (a LATER token creator becomes the +/// clone's own nearest publisher). Both were hand-modelled before and both are +/// now simply what the pass does. +/// +/// The probe is not byte-for-byte the finished chain, in two ways, and neither +/// changes the answer — measured on purpose-built fixtures, not argued: +/// +/// * the probe DEF: the clone the caller actually pushes differs only in its +/// `counter_type` and in keyword payloads rewritten inside `QuantityCheck` / +/// `TargetHasKeywordInstead` / `SourceLacksKeyword` +/// (`super::rewrite_ability_condition_keyword`) — no field either answer reads. +/// * the probe VECTOR: later passes APPEND defs after this binding runs, so the +/// vector here is a PREFIX of the finished chain (a fixture ending "… Repeat +/// this process for first strike. Then create a Soldier token." is examined at +/// 4 defs and finishes at 6), and the caller pushes one clone per listed +/// keyword where this pushes one. Both are benign: the re-link's verdict for a +/// node is a function of the defs BEFORE it, which appends leave index-stable, +/// and every clone is a copy of the same template landing consecutively on the +/// same path — two-keyword fixtures emit or decline both clones together, +/// never split. +pub(super) fn clone_would_transplant_gated_referent( + defs: &[AbilityDefinition], + template: usize, +) -> bool { + if !ability_reads_last_created(&defs[template]) { + return false; + } + let Some(publisher) = defs[..template] + .iter() + .rposition(|d| is_token_creating_effect(&d.effect)) + else { + return false; + }; + if !defs[publisher] + .condition + .as_ref() + .is_some_and(AbilityCondition::is_affirmative_reflexive_gate) + { + return false; + } + let mut probe = defs.to_vec(); + probe.push(super::repeat_process_clone_shape(&defs[template])); + relink_gated_token_referent_consumers(&mut probe); + let landed = probe.last().expect("pushed just above"); + landed.sub_link == SubAbilityLink::SequentialSibling && !landed.is_self_gated_reflexive() +} + /// CR 301.5 + CR 303.4: True when the nearest preceding token creator makes /// an Equipment or Aura token. Used to prefer the `attachment` slot for the /// post-token anaphor rewrite (`rewrite_parent_target_to_last_created`): in @@ -9953,15 +10279,17 @@ mod tests { use super::{ match_create_of_those_tokens, nest_whenever_this_turn_token_cleanup_delayed_trigger, parse_where_x_quantity_expression, patch_choose_from_zone_counter_continuation_target, - strip_redundant_flip_win_quantifier, strip_return_destination_ext_with_remainder, - strip_temporal_prefix, strip_temporal_suffix, strip_trailing_duration, - strip_trailing_where_x, value_quantity_clause_owns_this_turn_suffix, + relink_gated_token_referent_consumers, strip_redundant_flip_win_quantifier, + strip_return_destination_ext_with_remainder, strip_temporal_prefix, strip_temporal_suffix, + strip_trailing_duration, strip_trailing_where_x, + value_quantity_clause_owns_this_turn_suffix, }; use crate::parser::oracle_util::TextPair; use crate::types::ability::{ - AbilityDefinition, AbilityKind, AggregateFunction, ContinuousModification, - DelayedTriggerCondition, Duration, Effect, ObjectProperty, ObjectScope, PtValue, - QuantityExpr, QuantityRef, TargetFilter, TriggerDefinition, + AbilityCondition, AbilityDefinition, AbilityKind, AggregateFunction, + ContinuousModification, DelayedTriggerCondition, Duration, Effect, ModalChoice, + ObjectProperty, ObjectScope, PtValue, QuantityExpr, QuantityRef, SubAbilityLink, + TargetFilter, TriggerDefinition, }; use crate::types::counter::CounterType; use crate::types::phase::Phase; @@ -9984,6 +10312,110 @@ mod tests { } } + fn gated_token_creator_for_relink() -> AbilityDefinition { + let mut creator = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Token { + name: "Soldier".to_string(), + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + types: vec!["Creature".to_string(), "Soldier".to_string()], + colors: vec![], + keywords: vec![], + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![], + }, + ); + creator.condition = Some(AbilityCondition::WhenYouDo); + creator + } + + fn last_created_consumer_for_relink(target: TargetFilter) -> AbilityDefinition { + let mut consumer = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 1 }, + target, + }, + ); + consumer.sub_link = SubAbilityLink::SequentialSibling; + consumer + } + + /// CR 603.12 + CR 608.2c: a token referent hidden by any `TargetFilter` + /// wrapper still makes the following clause dependent on the gated token + /// creator, so the false branch cannot bind an earlier resolution's token. + #[test] + fn relink_follows_last_created_through_target_filter_wrappers() { + let wrapped_filters = [ + TargetFilter::Not { + filter: Box::new(TargetFilter::LastCreated), + }, + TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(0), + filter: Box::new(TargetFilter::LastCreated), + caused_by: None, + }, + TargetFilter::ChosenDamageSource { + filter: Some(Box::new(TargetFilter::LastCreated)), + }, + ]; + + for filter in wrapped_filters { + let mut defs = vec![ + gated_token_creator_for_relink(), + last_created_consumer_for_relink(filter), + ]; + relink_gated_token_referent_consumers(&mut defs); + assert_eq!( + defs[1].sub_link, + SubAbilityLink::ContinuationStep, + "a wrapped LastCreated reader must stay on the gated creator's continuation path" + ); + } + } + + /// CR 700.2 + CR 603.12: modal mode bodies are part of the containing + /// definition for the re-link decision. A `LastCreated` reader in a chosen + /// mode must not remain a standalone sibling of its gated token creator. + #[test] + fn relink_follows_last_created_through_modal_modes() { + let modal = ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 1, + ..Default::default() + }; + let consumer = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ) + .with_modal( + modal, + vec![last_created_consumer_for_relink(TargetFilter::LastCreated)], + ); + let mut defs = vec![gated_token_creator_for_relink(), consumer]; + defs[1].sub_link = SubAbilityLink::SequentialSibling; + + relink_gated_token_referent_consumers(&mut defs); + + assert_eq!( + defs[1].sub_link, + SubAbilityLink::ContinuationStep, + "a modal LastCreated reader must keep its wrapper on the gated continuation path" + ); + } + /// CR 608.2c: a `ChooseFromZone` head with a `RemoveCounter`/`PutCounter` /// `sub_ability` whose `target` is the `SelfRef` "it" anaphor (Amy Pond's /// "choose a suspended card you own and remove that many time counters from diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index c3fbce6f3b..ae0b69c2a9 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -42,17 +42,17 @@ use lower::{ apply_where_x_quantity_expression, compute_sentence_where_x, consolidate_die_and_coin_defs, extract_deal_damage_multi_target, extract_double_counter_multi_target, extract_put_counter_multi_target, extract_remove_counter_multi_target, - extract_switch_pt_multi_target, is_token_creating_effect, parse_damage_player_scope, - parse_for_each_opponent_target_fanout_clause, 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, strip_each_scope_who_does_subject, - strip_each_scope_who_doesnt_subject, strip_for_each_opponent_who_doesnt, strip_for_each_prefix, - strip_for_each_repeat_suffix, strip_leading_duration, strip_leading_quantifier, - strip_leading_return_destination_ext, strip_leading_sequence_connector, - strip_optional_effect_prefix, strip_player_scope_subject, strip_redundant_flip_win_quantifier, - strip_repeat_count_suffix, strip_return_destination_ext, + 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, + 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, + strip_each_scope_who_does_subject, strip_each_scope_who_doesnt_subject, + strip_for_each_opponent_who_doesnt, strip_for_each_prefix, strip_for_each_repeat_suffix, + strip_leading_duration, strip_leading_quantifier, strip_leading_return_destination_ext, + strip_leading_sequence_connector, strip_optional_effect_prefix, strip_player_scope_subject, + strip_redundant_flip_win_quantifier, strip_repeat_count_suffix, strip_return_destination_ext, strip_return_destination_ext_with_remainder, strip_temporal_prefix, strip_temporal_suffix, trim_dangling_target_word, try_parse_damage, try_parse_damage_with_remainder, try_parse_distribute_counters, try_parse_distribute_damage, @@ -18819,18 +18819,69 @@ fn chain_prior_referent_is_chosen_target(clauses: &[ClauseIr]) -> bool { /// SAFE direction only over-binds toward `LastCreated`, which the typed-target /// re-anchor bail prevents; a chain with no token creator falls through to /// `false`, leaving non-token self-triggers at `SelfRef`. +/// +/// CR 603.12 carve-out to the conditional bail: an AFFIRMATIVE reflexive gate +/// ("when you do" / "if you do") does NOT bail — see the in-body comment for +/// why, and for the paired re-link that makes it safe. fn chain_prior_referent_is_created_token(clauses: &[ClauseIr]) -> bool { - for prev in clauses.iter().rev() { - if prev.condition.is_some() { + // CR 608.2c: does an unbroken continuation path still run from the clause + // under inspection forward to the consumer being parsed? This is the + // parse-time PREDICTION of `lower::gated_instruction_reaches`, built from the + // same two authorities that pass reads off the assembled chain: + // `sub_link_after_boundary` (boundary → link) and + // `instruction_spine_is_continuation` (the within-clause spine). Consulted + // ONLY by the gated-publisher carve-out below — an UNGATED publisher's + // referent always exists, so its walk is unchanged. + let mut gated_publisher_reaches = true; + for (idx, prev) in clauses.iter().enumerate().rev() { + // CR 603.12 + CR 608.2c: an AFFIRMATIVE reflexive gate is stamped by + // `strip_if_you_do_conditional` onto the clause that FOLLOWS the + // connector — which, for a reflexive body that creates its own token + // (Iroh, Tea Master; Summoner's Sending), is the token-creating clause + // itself. That gate publishes no referent (CR 608.2c: the clause's + // effect does), so the token is the chain's most-recent object referent + // for a later anaphor in the same body. Every OTHER condition keeps the + // bail: for those, `resolve_ability_chain`'s condition-false descent + // deliberately still resolves the next independent `SequentialSibling` + // instruction, and `state.last_created_token_ids` is a game-lifetime + // ledger, so a `LastCreated` seed there would bind a STALE token. + if prev + .condition + .as_ref() + .is_some_and(|c| !c.is_affirmative_reflexive_gate()) + { return false; } + let spine_reaches = prev + .parsed + .sub_ability + .as_deref() + .is_none_or(instruction_spine_is_continuation); if is_token_creating_effect(&prev.parsed.effect) { - return true; + // 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 + // consuming clause INTO the gated instruction — which it does only + // when the continuation path is unbroken. Predicting the same + // condition here is what keeps the two halves one rule: the seeder + // can never bind `LastCreated` on a shape the re-link declines, + // which is what would leave a STALE bind behind. Any condition still + // present at this point is an affirmative gate (the bail above took + // every other value), so `is_none` is exactly "ungated". + return prev.condition.is_none() || (gated_publisher_reaches && spine_reaches); } if has_typed_target_widened(&prev.parsed.effect) { // A later explicit typed target re-anchors "it" to itself. return false; } + // This clause is being skipped; record whether it breaks the path. Its + // own link comes from the boundary printed BEFORE it — i.e. the boundary + // trailing `clauses[idx - 1]`, which is exactly the `prev_boundary` the + // assembler stamps it from (every clause advances `prev_boundary`, + // including the special/absorbed ones that emit no definition). + gated_publisher_reaches &= spine_reaches + && sub_link_after_boundary(idx.checked_sub(1).and_then(|p| clauses[p].boundary)) + == SubAbilityLink::ContinuationStep; // Carrier / non-referent clause (e.g. "It gains haste") — keep scanning. } false @@ -22360,6 +22411,19 @@ pub(super) fn def_is_dig_look(def: &AbilityDefinition) -> bool { /// U6-C5: the template is bound by `AntecedentRole::KeywordCounterPlacement` in /// `assembly.rs` and handed in as `template_index`; the replicated siblings are /// still pushed onto `defs`. +/// +/// CR 603.12: each clone carries the template's target VERBATIM and is pushed at +/// the TAIL of `defs`, so a template that reads a gated publisher's +/// `TargetFilter::LastCreated` would have that chain-context referent +/// transplanted past whatever sits in between, where +/// `lower::relink_gated_token_referent_consumers` can no longer reach it and +/// `state.last_created_token_ids` — a game-lifetime ledger — would bind a token +/// from an EARLIER resolution. The caller therefore declines the binding when +/// `lower::clone_would_transplant_gated_referent` holds — a predicate that +/// answers by building the clone ([`repeat_process_clone_shape`]) and asking +/// `lower::relink_gated_token_referent_consumers` whether it lands honestly. +/// Every other template is replicated as printed (CR 608.2c: the controller +/// follows the instructions in the order written). fn attach_repeat_process_keywords( defs: &mut Vec, template_index: usize, @@ -22367,26 +22431,42 @@ fn attach_repeat_process_keywords( ) { let template = defs[template_index].clone(); for keyword in keywords { - let mut new_def = template.clone(); + let mut new_def = repeat_process_clone_shape(&template); if let Effect::PutCounter { counter_type, .. } = &mut *new_def.effect { *counter_type = CounterType::Keyword(keyword.kind()); } if let Some(condition) = &mut new_def.condition { rewrite_ability_condition_keyword(condition, keyword); } - // Each replicated counter placement is its own sequential instruction. - new_def.sub_link = SubAbilityLink::SequentialSibling; - // CR 608.2c: each per-keyword sibling is an INDEPENDENT OR-branch gated on - // its own keyword, so it must resolve even when a preceding sibling's - // condition (K0's graveyard-keyword gate) was false. Without this, K1..Kn - // never place their counters once K0's gate fails (the same "list - // collapse" bug this marker fixes for Mutable Pupa's perpetual grants). - new_def.sibling_condition = SiblingCondition::ReplicatedOrBranch; - new_def.sub_ability = None; defs.push(new_def); } } +/// The chain shape every clone pushed by [`attach_repeat_process_keywords`] +/// carries: a verbatim copy of the template that is its own sequential +/// instruction with no sub-chain of its own. +/// +/// Single authority, because `lower::clone_would_transplant_gated_referent` +/// decides whether the binding is safe by building this exact def and asking +/// `lower::relink_gated_token_referent_consumers` what it would do with it at +/// the tail. If the two shapes diverged, the guard would answer a question about +/// a def the assembler never pushes. +/// +/// CR 608.2c: each per-keyword sibling is an INDEPENDENT OR-branch gated on its +/// own keyword, so it must resolve even when a preceding sibling's condition +/// (K0's graveyard-keyword gate) was false. Without +/// `SiblingCondition::ReplicatedOrBranch`, K1..Kn never place their counters +/// once K0's gate fails — the "list collapse" bug fixed for Mutable Pupa's +/// perpetual grants (#6533). It lives here rather than at the push site so the +/// guard's simulated clone carries it too. +pub(super) fn repeat_process_clone_shape(template: &AbilityDefinition) -> AbilityDefinition { + let mut new_def = template.clone(); + new_def.sub_link = SubAbilityLink::SequentialSibling; + new_def.sibling_condition = SiblingCondition::ReplicatedOrBranch; + new_def.sub_ability = None; + new_def +} + /// Membership mirror for `AntecedentRole::KeywordCounterPlacement` — the shape /// `attach_repeat_process_keywords` clones its sibling template from. pub(super) fn def_is_keyword_counter_placement(def: &AbilityDefinition) -> bool { diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 7337a906c1..4a051add21 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -8,7 +8,7 @@ use crate::types::ability::{ LibraryPosition, ManaProduction, ManaSpendRestriction, ModalSelectionConstraint, OutsideGameSourcePool, PlayerFilter, PtStat, PtValue, QuantityExpr, SearchDestinationSplit, SearchSelectionConstraint, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, - TargetFilter, + SubAbilityLink, TargetFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::CounterType; @@ -1649,6 +1649,26 @@ pub(crate) enum ClauseBoundary { Comma, } +/// CR 608.2c: the SINGLE translation from the printed boundary that precedes a +/// clause to the link the clause carries to the one before it. A `Sentence` +/// boundary marks the next printed instruction (`SequentialSibling`); a +/// `Comma`/`Then`/absent boundary marks a within-clause `ContinuationStep`. +/// +/// Two passes need this mapping and MUST agree: `assemble_effect_chain` stamps +/// `AbilityDefinition::sub_link` with it, and the referent walk +/// (`parser::oracle_effect::chain_prior_referent_is_created_token`) predicts, +/// while still in `ClauseIr` space, whether the clauses it walks past will be +/// continuation steps. Keeping the match here is what makes those two the same +/// rule rather than two copies of it. +pub(crate) fn sub_link_after_boundary(boundary: Option) -> SubAbilityLink { + match boundary { + Some(ClauseBoundary::Sentence) => SubAbilityLink::SequentialSibling, + Some(ClauseBoundary::Then) | Some(ClauseBoundary::Comma) | None => { + SubAbilityLink::ContinuationStep + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct ClauseChunk { pub(crate) text: String, diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 99160a2eed..16264ddcef 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -16821,22 +16821,32 @@ impl<'de> Deserialize<'de> for AbilityDefinition { /// CR 608.2c: How a `sub_ability` relates to its parent in the resolution chain. /// Determines whether the sub is part of the parent's action (skipped when an /// optional parent is declined) or an independent following instruction (always -/// resolves). Derived at parse time from the `ClauseBoundary` separating the -/// two clauses in the printed Oracle text. +/// resolves). Seeded at parse time from the `ClauseBoundary` separating the two +/// clauses in the printed Oracle text. +/// +/// **The value is semantic, not purely syntactic.** The sentence boundary is the +/// default reading, but a later parse pass may override it when the printed text +/// makes a separate sentence part of the previous instruction anyway — see +/// `parser::oracle_effect::lower::relink_gated_token_referent_consumers`, which +/// re-tags a separate-sentence clause whose only subject is a token created by a +/// gated clause before it ("…create a token. Put a +1/+1 counter on that +/// token."). Do not add a consumer that infers a *sentence boundary* from this +/// field; infer only the *dependency* the variant docs describe. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum SubAbilityLink { - /// Within-sentence continuation (comma / "then" joined). The sub is a - /// resolution step of the parent's instruction — Squadron Hawk + /// The sub is a resolution step of the parent's instruction — Squadron Hawk /// "...put them into your hand, then shuffle." Skipped when an optional - /// parent is declined. This is the default: an unmarked sub is a - /// continuation, preserving today's runtime behavior for every existing + /// parent is declined. Ordinarily a within-sentence continuation (comma / + /// "then" joined), but also a separate sentence that depends on the + /// parent's action for its subject. This is the default: an unmarked sub is + /// a continuation, preserving today's runtime behavior for every existing /// chain. #[default] ContinuationStep, - /// Separate-sentence sibling instruction (sentence boundary). The sub is - /// the NEXT printed instruction, independent of the parent — Ponder - /// "You may shuffle." "Draw a card." Always resolves, even when an + /// The sub is the NEXT printed instruction, independent of the parent — + /// Ponder "You may shuffle." "Draw a card." Always resolves, even when an /// optional parent is declined (CR 608.2c "in the order written"). + /// Ordinarily produced by a sentence boundary. SequentialSibling, } @@ -17166,6 +17176,64 @@ impl AbilityDefinition { self.mode_abilities = mode_abilities; self } + + /// CR 603.12: Does this clause carry its own copy of an affirmative + /// reflexive gate, in the narrow POSITION-INDEPENDENT form, so that the + /// resolver drops it on the gate-false path wherever it sits in the chain? + /// + /// Lives here rather than in the parser because + /// `parser::oracle_effect::lower::clone_would_transplant_gated_referent` + /// uses it to decide whether a "Repeat this process for …" clone is honest + /// at its landing slot, and the `no_sequential_sibling_reads_last_created` + /// regression test must exempt exactly the nodes that predicate accepts. + /// `pub` specifically because a test under `crates/engine/tests/` cannot + /// call a private `fn` in `lower`, so the alternative was a second copy of + /// the predicate inside the test. + /// + /// It is NOT the engine's single authority for this shape, and does not + /// claim to be. `game::effects::resolve_optional_effect_decision` carries a + /// structurally identical inlined copy on the RUNTIME twin type — the same + /// two fields, the same `AbilityCondition::is_optional_effect_performed`, + /// the same conjunction — and it cannot call this method: its receiver is + /// `ResolvedAbility`, not `AbilityDefinition`, with no `Deref`/`From` + /// bridge between them (measured, not assumed: wiring the call there fails + /// to compile with E0599, method not found for `&ResolvedAbility`). + /// Unifying the two would take a trait over both types — a resolver-side + /// abstraction with its own blast radius, which this change deliberately + /// does not introduce. + /// + /// `parser::oracle_effect::attach_repeat_process_keywords` clones + /// `condition` and `else_ability` verbatim, and the resolver's + /// condition-false path (`game::effects::resolve_ability_chain`) reads BOTH + /// — at two different places, which is why both are tested here: + /// + /// * a false condition never runs the clause's own effect; `else_ability` + /// decides what runs INSTEAD. With an else present the resolver resolves + /// the else branch and RETURNS, so the clause's `sub_ability` tail never + /// runs at all and the cloned else — a second copy of a printed + /// instruction — executes. "The descent just drops this clause" would then + /// be the wrong description of the outcome. + /// * with no else present the resolver walks `sub_ability` for the next + /// `SequentialSibling`, skipping one whose own condition is false and + /// which itself has no else. + /// + /// Only `EffectOutcome { OptionalEffectPerformed }` qualifies, NOT the whole + /// of [`AbilityCondition::is_affirmative_reflexive_gate`]. Its evaluator + /// reads `context.optional_effect_performed` and + /// `state.cost_payment_failed_flag`, which are chain-scoped and therefore + /// position-independent. `WhenYouDo`'s evaluator is + /// `!(matches!(ability.effect, PayCost | Discard | DiscardCard) && + /// cost_payment_failed_flag)` — it keys on the effect it RIDES, so on a + /// cloned `PutCounter` it is a constant `true` and gates nothing. Measured: + /// the same fixture with `If you do` leaves a stale pre-seeded token at `{}` + /// and with `When you do` puts a `Keyword(FirstStrike)` counter on it. + pub fn is_self_gated_reflexive(&self) -> bool { + self.else_ability.is_none() + && self + .condition + .as_ref() + .is_some_and(AbilityCondition::is_optional_effect_performed) + } } /// The result of an `Effect::OpponentGuess` round-trip. @@ -17672,6 +17740,99 @@ impl AbilityCondition { matches!(self, AbilityCondition::EffectOutcome { .. }) } + /// CR 603.12 + CR 608.2c: True for the AFFIRMATIVE reflexive-conditional + /// gates — exactly the image of + /// `parser::oracle_nom::condition::parse_affirmative_reflexive_connector` + /// ("when you do", "if you do", "if they do", "if that player does", + /// "if the player does", "if a player does"). + /// + /// Such a gate does not introduce an object referent (CR 608.2c: the + /// clause's *effect* publishes the referent, its gate does not), and its + /// true-branch is exactly "the antecedent action occurred" (CR 603.12). Two + /// parse-time sites consume this classification and MUST agree: the referent + /// walk (`parser::oracle_effect::chain_prior_referent_is_created_token`) and + /// the re-link pass + /// (`parser::oracle_effect::lower::relink_gated_token_referent_consumers`). + /// Widening one without the other re-opens the stale-`LastCreated` bind. + /// + /// Classified on the VALUE, not on the phrase that produced it: + /// `AbilityCondition::effect_performed()` is also constructed at + /// non-connector sites, and the classification is sound for all of them + /// because the value itself means "the antecedent optional effect was + /// performed" — a token created under it may not exist, whatever text minted + /// the gate. + /// + /// The NEGATED half (`if they don't` → `Not{OptionalEffectPerformed}`) is + /// deliberately excluded: its body runs precisely when the antecedent did + /// NOT happen, so folding the gate away would invert the guarantee. + /// + /// Exhaustive, no wildcard: a new `AbilityCondition` variant must be + /// classified here deliberately, not silently defaulted (mirrors + /// `game::effects::should_resolve_subability_on_optional_decline`). + pub fn is_affirmative_reflexive_gate(&self) -> bool { + match self { + AbilityCondition::WhenYouDo + | AbilityCondition::EffectOutcome { + signal: EffectOutcomeSignal::OptionalEffectPerformed, + } => true, + AbilityCondition::EffectOutcome { + signal: + EffectOutcomeSignal::CurrentScopeSucceeded | EffectOutcomeSignal::Guessed { .. }, + } => false, + AbilityCondition::AdditionalCostPaidInstead + | AbilityCondition::AlternativeManaCostPaid + | AbilityCondition::EventOutcomeWon + | AbilityCondition::SourceEnteredThisTurn + | AbilityCondition::HasMaxSpeed + | AbilityCondition::IsMonarch + | AbilityCondition::IsInitiative + | AbilityCondition::HasCityBlessing + | AbilityCondition::IsRingBearer + | AbilityCondition::HasObjectTarget + | AbilityCondition::IsYourTurn + | AbilityCondition::FirstCombatPhaseOfTurn + | AbilityCondition::FirstEndStepOfTurn + | AbilityCondition::SourceIsTapped + | AbilityCondition::SourceAttachedToCreature + | AbilityCondition::DayNightIsNeither + | AbilityCondition::AdditionalCostPaid { .. } + | AbilityCondition::CoinFlipOutcome { .. } + | AbilityCondition::WasCast { .. } + | AbilityCondition::CastDuringPhase { .. } + | AbilityCondition::CurrentPhaseIs { .. } + | AbilityCondition::CastTimingPermission { .. } + | AbilityCondition::ManaColorSpent { .. } + | AbilityCondition::RevealedHasCardType { .. } + | AbilityCondition::ObjectsShareQuality { .. } + | AbilityCondition::TargetSharesNameWithOtherExiledThisWay { .. } + | AbilityCondition::CastVariantPaid { .. } + | AbilityCondition::CastVariantPaidInstead { .. } + | AbilityCondition::QuantityCheck { .. } + | AbilityCondition::PreviousEffectAmount { .. } + | AbilityCondition::CompletedDungeon { .. } + | AbilityCondition::TargetHasKeywordInstead { .. } + | AbilityCondition::TargetMatchesFilter { .. } + | AbilityCondition::TriggeringSpellTargetsFilter { .. } + | AbilityCondition::SourceMatchesFilter { .. } + | AbilityCondition::PostReplacementDamageSourceMatchesFilter { .. } + | AbilityCondition::ZoneChangeObjectMatchesFilter { .. } + | AbilityCondition::ControllerControlsMatching { .. } + | AbilityCondition::ControllerControlledMatchingAsCast { .. } + | AbilityCondition::WasStartingPlayer { .. } + | AbilityCondition::SpellCastWithVariantThisTurn { .. } + | AbilityCondition::ZoneChangedThisWay { .. } + | AbilityCondition::CostPaidObjectMatchesFilter { .. } + | AbilityCondition::ConditionInstead { .. } + | AbilityCondition::And { .. } + | AbilityCondition::Or { .. } + | AbilityCondition::Not { .. } + | AbilityCondition::DayNightIs { .. } + | AbilityCondition::NthResolutionThisTurn { .. } + | AbilityCondition::SourceLacksKeyword { .. } + | AbilityCondition::ScopedPlayerMatches { .. } => false, + } + } + pub fn is_optional_effect_performed(&self) -> bool { matches!( self, diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index c5f1c7b455..6bb421bec8 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1059,6 +1059,7 @@ mod priest_of_the_crossing_died_under_control; mod quick_draw_target_opponent; mod rankle_and_torbran; mod razorkin_needlehead_opponent_draw_damage_2869; +mod reflexive_body_token_referent; mod reflexive_discard_this_way; mod reflexive_if_rider; mod reflexive_this_way_delayed_s25; diff --git a/crates/engine/tests/integration/reflexive_body_token_referent.rs b/crates/engine/tests/integration/reflexive_body_token_referent.rs new file mode 100644 index 0000000000..099d3216c9 --- /dev/null +++ b/crates/engine/tests/integration/reflexive_body_token_referent.rs @@ -0,0 +1,1826 @@ +//! BB-FU9 — a clause whose subject is the token published by a clause under an +//! AFFIRMATIVE reflexive gate ("When you do, create a token. Put a +1/+1 counter +//! on that token.") binds that token, and is re-linked onto the gated +//! instruction's continuation path so the resolver's condition-false descent +//! cannot resolve it against a STALE entry of `state.last_created_token_ids` — +//! a game-lifetime ledger that is never cleared at a resolution boundary +//! (CR 603.12 + CR 608.2c + CR 609.3). +//! +//! Every row drives the REAL pipeline — `parse_oracle_text` / +//! `parse_effect_chain`, then `build_resolved_from_def_with_targets` + +//! `resolve_ability_chain` + `runner.act(DecideOptionalEffect)` — and asserts +//! RESOLVED board state. Four rows (T14, T15, T18, T19) are explicitly +//! AST-SHAPE rows: what they pin is that a `sub_link` does not LIE about which +//! instruction a clause belongs to, which is a parse-time property with no +//! behavioural twin (the condition-false descent resolves the clause either +//! way, from a different parent). +//! +//! **`engine::ai_support::legal_actions()` is never called here.** It simulates +//! every candidate action on a CLONE and silently swallows the mutation being +//! measured; this lane read a false 0 that way before. +//! +//! Synthetic Oracle text (T8, T12–T15, T17, T19, T20) is disclosed as such and +//! is templated verbatim from shipped patterns: "you may pay {N}. If you do, +//! create …" (Akoum Stonewaker, Krenko, Cadric) and "If you didn't create a +//! token this way, create …" (Springheart Nantuko). The two-publisher and +//! intervening-instruction shapes have no shipped witness, which is why they +//! are synthetic. Every shipped-card claim is asserted on the shipped card +//! (T1, T2, T3, T5, T6, T7, T9, T10, T11, T16, T18, T21), with Oracle text +//! byte-verbatim from MTGJSON `AtomicCards.json`. + +use engine::game::ability_utils::build_resolved_from_def_with_targets; +use engine::game::effects::resolve_ability_chain; +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::zones::create_object; +use engine::parser::oracle::parse_oracle_text; +use engine::parser::oracle_effect::parse_effect_chain; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, SubAbilityLink, TargetFilter, TargetRef, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::keywords::KeywordKind; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +// ───────────────────────────────────────────────────────────────────────────── +// Shipped-card Oracle text — byte-verbatim from `data/mtgjson/AtomicCards.json`. +// A paraphrase can take a different parser branch, so a paraphrased fixture can +// go green while the real card stays broken. +// ───────────────────────────────────────────────────────────────────────────── + +const IROH: &str = "When Iroh enters, create a Food token.\nAt the beginning of combat on your turn, you may have target opponent gain control of target permanent you control. When you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token for each permanent you own that your opponents control."; +/// The Iroh reflexive BODY with the gate stripped — the ungated positive +/// control for T4. +const IROH_BODY: &str = "Create a 1/1 white Ally creature token. Put a +1/+1 counter on that token for each permanent you own that your opponents control."; +const SUMMONERS_SENDING: &str = "At the beginning of your end step, you may exile target creature card from a graveyard. If you do, create a 1/1 white Spirit creature token with flying. Put a +1/+1 counter on it if the exiled card's mana value is 4 or greater."; +const NORTH_POLE: &str = "At the beginning of your upkeep, target opponent draws a card and creates a Treasure token.\nWhenever chaos ensues, create a 2/2 white Alien creature token. When you do, tap target nontoken creature an opponent controls. Put a stun counter on it. (If a permanent with a stun counter would become untapped, remove one from it instead.)"; +const RATONHNHAKETON: &str = "As long as Ratonhnhak\u{e9}\u{a789}ton hasn't dealt damage yet, it has hexproof and can't be blocked.\nWhenever Ratonhnhak\u{e9}\u{a789}ton deals combat damage to a player, create a 1/1 black Assassin creature token with menace. When you do, return target Equipment card from your graveyard to the battlefield, then attach it to that token."; +const AKOUM: &str = "Landfall \u{2014} Whenever a land you control enters, you may pay {2}{R}. If you do, create a 3/1 red Elemental creature token with trample and haste. Exile that token at the beginning of the next end step."; +const SAHEELI: &str = "Whenever you cast an Artificer or artifact spell, you get {E} (an energy counter).\nAt the beginning of combat on your turn, you may pay {E}{E}{E}. When you do, create a token that's a copy of target permanent you control, except it's a 5/5 artifact creature in addition to its other types and has haste. Sacrifice it at the beginning of the next end step."; +const CADRIC: &str = "The \"legend rule\" doesn't apply to tokens you control.\nWhenever another nontoken legendary permanent you control enters, you may pay {1}. If you do, create a token that's a copy of it. That token gains haste. Sacrifice it at the beginning of the next end step."; +const REBELLION: &str = "Whenever you clash, you may pay {1}. If you do, create a 3/1 red Elemental Shaman creature token. If you won, that token gains haste until end of turn. (This ability triggers after the clash ends.)"; + +// ───────────────────────────────────────────────────────────────────────────── +// Synthetic building-block fixtures (disclosed above). +// ───────────────────────────────────────────────────────────────────────────── + +const BASE_IFYOUDO: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token."; +const BASE_WHENYOUDO: &str = "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token."; +const COMPLEMENT: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. If you didn't create a token this way, create a 1/1 green Insect creature token."; +const INTERVENING_UNGATED: &str = "When this creature enters, create a 1/1 white Ally creature token. You may pay {2}. If you do, draw a card. Put a +1/+1 counter on that token."; +const TWOPUB_GATED_LAST: &str = "When this creature enters, create a 1/1 white Ally creature token. You may pay {2}. If you do, create a 1/1 green Insect creature token. Put a +1/+1 counter on that token."; +const TWOPUB_UNGATED_LAST: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 green Insect creature token. Create a 1/1 white Ally creature token. Put a +1/+1 counter on that token."; +const GAP1_IFYOUDO: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. You gain 1 life. Put a +1/+1 counter on that token."; +const GAP1_WHENYOUDO: &str = "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token. You gain 1 life. Put a +1/+1 counter on that token."; +const GAP1_DELAYED: &str = "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. You gain 1 life. Exile that token at the beginning of the next end step."; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared helpers — each used by more than one test. +// ───────────────────────────────────────────────────────────────────────────── + +fn add_object(runner: &mut GameRunner, player: PlayerId, name: &str, zone: Zone) -> ObjectId { + let state = runner.state_mut(); + let card_id = CardId(state.next_object_id); + create_object(state, card_id, player, name.to_string(), zone) +} + +fn add_creature(runner: &mut GameRunner, player: PlayerId, name: &str) -> ObjectId { + let id = add_object(runner, player, name, Zone::Battlefield); + let obj = runner.state_mut().objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(2); + obj.toughness = Some(2); + id +} + +fn counter(runner: &GameRunner, id: ObjectId, kind: &CounterType) -> u32 { + runner.state().objects[&id] + .counters + .get(kind) + .copied() + .unwrap_or(0) +} + +fn p1p1(runner: &GameRunner, id: ObjectId) -> u32 { + counter(runner, id, &CounterType::Plus1Plus1) +} + +fn kw_counter(runner: &GameRunner, id: ObjectId, k: KeywordKind) -> u32 { + counter(runner, id, &CounterType::Keyword(k)) +} + +/// The multi-authority second `LastCreated` candidate: a token from an EARLIER +/// resolution, sitting in the game-lifetime `last_created_token_ids` ledger. A +/// stale bind lands HERE, so every negative assertion names this object rather +/// than saying "not the source". +fn pre_seed_decoy(runner: &mut GameRunner) -> ObjectId { + let decoy = add_creature(runner, P0, "STALE DECOY TOKEN"); + runner.state_mut().objects.get_mut(&decoy).unwrap().is_token = true; + runner.state_mut().last_created_token_ids = vec![decoy]; + decoy +} + +/// In-test revert-probe for the §7.3 re-link pass: restore the BASE +/// `SequentialSibling` link on every node the pass re-tagged. +fn unrelink(def: &mut AbilityDefinition) { + if let Some(sub) = def.sub_ability.as_deref_mut() { + if reads_last_created(sub) && sub.sub_link == SubAbilityLink::ContinuationStep { + sub.sub_link = SubAbilityLink::SequentialSibling; + } + unrelink(sub); + } + if let Some(e) = def.else_ability.as_deref_mut() { + unrelink(e); + } +} + +/// Grant mana only when `funded`. `drive_ship` seeds energy separately, so a +/// false fixture can still drive Saheeli's `{E}{E}{E}` gate false in T16. +fn fund(scenario: &mut GameScenario, funded: bool) { + if funded { + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Red, ObjectId(9_999), false, vec![]); 6], + ); + } +} + +/// Descend a definition's spine by a path of `s` (sub_ability) / `e` +/// (else_ability) steps: `""` is the definition itself, `"ss"` its +/// grandchild. +fn node_at<'a>(def: &'a AbilityDefinition, path: &str) -> &'a AbilityDefinition { + let mut cursor = def; + for (i, step) in path.chars().enumerate() { + cursor = match step { + 's' => cursor.sub_ability.as_deref(), + 'e' => cursor.else_ability.as_deref(), + other => panic!("bad path step {other:?}"), + } + .unwrap_or_else(|| panic!("path {path:?} has no node at step {i}")); + } + cursor +} + +fn sub_link_at(def: &AbilityDefinition, path: &str) -> SubAbilityLink { + node_at(def, path).sub_link +} + +/// The `TargetFilter` a named node's effect carries — the seeder-side property +/// (`PutCounter.target`), which `sub_link` alone cannot see. +fn target_at(def: &AbilityDefinition, path: &str) -> TargetFilter { + node_at(def, path) + .effect + .target_filter() + .cloned() + .unwrap_or_else(|| panic!("node at {path:?} carries no target filter")) +} + +/// Does any position inside this ONE node's effect read `LastCreated`? Mirrors +/// `lower::ability_reads_last_created`'s coverage (`GenericEffect` grant +/// recipients and a `CreateDelayedTrigger`'s inner definition included) by +/// asking the serialized effect, so the answer does not depend on an +/// enumeration of which `Effect` variants can carry the referent. +fn effect_reads_last_created(def: &AbilityDefinition) -> bool { + serde_json::to_string(&*def.effect) + .expect("effect serializes") + .contains("\"LastCreated\"") +} + +/// Same question over the whole within-clause chain (node + sub + else). +fn reads_last_created(def: &AbilityDefinition) -> bool { + effect_reads_last_created(def) + || def.sub_ability.as_deref().is_some_and(reads_last_created) + || def.else_ability.as_deref().is_some_and(reads_last_created) +} + +/// Visit this node and every node reachable through `sub_ability` / +/// `else_ability`. +fn walk(def: &AbilityDefinition, visit: &mut impl FnMut(&AbilityDefinition)) { + visit(def); + if let Some(s) = def.sub_ability.as_deref() { + walk(s, visit); + } + if let Some(e) = def.else_ability.as_deref() { + walk(e, visit); + } +} + +/// Every `AbilityDefinition` root a parsed card carries. +fn card_definitions(name: &str, text: &str, types: &[&str]) -> Vec { + let types: Vec = types.iter().map(|s| (*s).to_string()).collect(); + let parsed = parse_oracle_text(text, name, &[], &types, &[]); + parsed + .abilities + .into_iter() + .chain( + parsed + .triggers + .into_iter() + .filter_map(|t| t.execute) + .map(|b| *b), + ) + .chain( + parsed + .replacements + .into_iter() + .filter_map(|r| r.execute) + .map(|b| *b), + ) + .collect() +} + +fn parse_trigger(text: &str, name: &str, types: &[&str], index: usize) -> AbilityDefinition { + let types: Vec = types.iter().map(|s| (*s).to_string()).collect(); + let parsed = parse_oracle_text(text, name, &[], &types, &[]); + *parsed + .triggers + .get(index) + .unwrap_or_else(|| panic!("{name} has a trigger[{index}]")) + .execute + .clone() + .unwrap_or_else(|| panic!("{name} trigger[{index}] has an execute")) +} + +fn resolve_def( + runner: &mut GameRunner, + def: &AbilityDefinition, + source: ObjectId, + targets: Vec, +) { + let resolved = build_resolved_from_def_with_targets(def, source, P0, targets); + let mut events = Vec::new(); + let _ = resolve_ability_chain(runner.state_mut(), &resolved, &mut events, 0); +} + +/// Answer the optional-payment prompt through the real action pipeline. +fn decide(runner: &mut GameRunner, accept: bool) -> bool { + runner + .act(GameAction::DecideOptionalEffect { accept }) + .is_ok() +} + +/// Answer a sub-clause's `TriggerTargetSelection` prompt and let the resulting +/// stack object resolve. Used where the targeted clause is NOT the chain's head +/// (North Pole's `SetTapState`, Ratonhnhaké꞉ton's `ChangeZone`), so its target +/// cannot be pre-wired into `build_resolved_from_def_with_targets`'s slot list +/// and must go through the real `apply()` action pipeline. +fn select_targets_and_resolve(runner: &mut GameRunner, targets: Vec) { + runner + .act(GameAction::SelectTargets { targets }) + .expect("the reflexive body's target slot accepts the declared target"); + runner.advance_until_stack_empty(); +} + +/// Tokens created during THIS resolution (the decoy is excluded by id). +fn live_tokens(runner: &GameRunner, decoy: ObjectId) -> Vec { + let mut v: Vec = runner + .state() + .battlefield + .iter() + .copied() + .filter(|id| runner.state().objects[id].is_token && *id != decoy) + .collect(); + v.sort(); + v +} + +fn token_named(runner: &GameRunner, decoy: ObjectId, name: &str) -> ObjectId { + live_tokens(runner, decoy) + .into_iter() + .find(|id| runner.state().objects[id].name == name) + .unwrap_or_else(|| panic!("a token named {name:?} was created")) +} + +/// Does any pending delayed trigger target `id`? +fn delayed_targets(runner: &GameRunner, id: ObjectId) -> bool { + runner + .state() + .delayed_triggers + .iter() + .any(|d| d.ability.targets.contains(&TargetRef::Object(id))) +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T1–T7 — shipped cards, gate-TRUE and the declines. +// ═════════════════════════════════════════════════════════════════════════════ + +/// T1 — Iroh's reflexive body ("When you do, create a 1/1 white Ally creature +/// token. Put a +1/+1 counter on that token for each permanent you own that +/// your opponents control.") binds the ALLY TOKEN, not the permanent given away +/// by the antecedent. +/// +/// FLIPS: reverting the §7.2 gated-publisher carve-out re-binds the counter to +/// the given-away Bear — `bear +1/+1 == 0` reds (it becomes 1) and +/// `ally +1/+1 == 1` reds (it becomes 0). +#[test] +fn iroh_reflexive_body_counter_binds_created_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Iroh, Tea Master"); + let bear = add_creature(&mut runner, P0, "Bear"); + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(IROH, "Iroh, Tea Master", &["Creature"], 1); + resolve_def( + &mut runner, + &def, + source, + vec![TargetRef::Object(bear), TargetRef::Player(P1)], + ); + assert!(decide(&mut runner, true), "the reflexive gate was accepted"); + + let ally = token_named(&runner, decoy, "Ally"); + assert_eq!( + p1p1(&runner, ally), + 1, + "'that token' binds the Ally created by the reflexive body" + ); + assert_eq!( + p1p1(&runner, bear), + 0, + "the given-away permanent (the antecedent's target) receives NO counter" + ); + assert_eq!( + p1p1(&runner, decoy), + 0, + "the stale decoy receives no counter" + ); +} + +/// T2 — Summoner's Sending: the counter lands on the SPIRIT token, not on the +/// creature card the antecedent exiled. +/// +/// FLIPS: reverting the §7.2 carve-out moves the counter from the token +/// (1 → 0) onto the exiled card (0 → 1). +#[test] +fn summoners_sending_counter_binds_created_token() { + let (runner, decoy, card) = drive_sending(4); + let spirit = token_named(&runner, decoy, "Spirit"); + + assert_eq!( + p1p1(&runner, spirit), + 1, + "'it' binds the created Spirit token (exiled card MV 4 satisfies the gate)" + ); + assert_eq!( + p1p1(&runner, card), + 0, + "the exiled creature card receives NO counter" + ); +} + +/// T3 — adjacent-value hostile: the same card with an MV-1 exiled card places +/// NO counter, and the negative is reach-guarded (the Spirit token really was +/// created and the card really is in exile, so the assertion cannot be +/// satisfied by an upstream short-circuit). +#[test] +fn summoners_sending_low_mv_places_no_counter() { + let (runner, decoy, card) = drive_sending(1); + let spirit = token_named(&runner, decoy, "Spirit"); + + // Reach-guards. + assert!( + runner.state().objects[&spirit].is_token, + "reach-guard: the Spirit token WAS created, so the consumer had a live referent" + ); + assert_eq!( + runner.state().objects[&card].zone, + Zone::Exile, + "reach-guard: the antecedent exile really happened (the mana-value input exists)" + ); + + assert_eq!( + p1p1(&runner, spirit), + 0, + "mana value 1 fails the 'is 4 or greater' condition — no counter" + ); + assert_eq!(p1p1(&runner, card), 0, "and none on the exiled card either"); +} + +/// Shared Summoner's Sending drive: a creature card of mana value `mv` in a +/// graveyard, a pre-seeded decoy, the reflexive exile ACCEPTED. +fn drive_sending(mv: u32) -> (GameRunner, ObjectId, ObjectId) { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Summoner's Sending"); + let card = add_object(&mut runner, P0, "Fat Creature", Zone::Graveyard); + { + let obj = runner.state_mut().objects.get_mut(&card).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.mana_cost = ManaCost::Cost { + generic: mv, + shards: vec![], + }; + } + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(SUMMONERS_SENDING, "Summoner's Sending", &["Enchantment"], 0); + resolve_def(&mut runner, &def, source, vec![TargetRef::Object(card)]); + assert!( + decide(&mut runner, true), + "the reflexive exile was accepted" + ); + (runner, decoy, card) +} + +/// T4 — positive control. The Iroh BODY with no reflexive connector at all +/// still binds the created token, proving the harness, the per-permanent count +/// math and `LastCreated` resolution work independently of the gate. +#[test] +fn ungated_chain_still_binds_created_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Ungated Body"); + // One permanent P0 OWNS that an opponent CONTROLS — the "for each" count's + // only member, so the expected value is 1 rather than a vacuous 0. + // `base_controller` is load-bearing: the layer pass rewrites + // `obj.controller` from `base_controller.unwrap_or(owner)` on every + // recompute, so setting `controller` alone is silently undone. + let loaned = add_creature(&mut runner, P0, "Loaned Bear"); + { + let obj = runner.state_mut().objects.get_mut(&loaned).unwrap(); + obj.controller = P1; + obj.base_controller = Some(P1); + } + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_effect_chain(IROH_BODY, AbilityKind::Spell); + resolve_def(&mut runner, &def, source, vec![]); + + let ally = token_named(&runner, decoy, "Ally"); + // Reach-guard: the dynamic count really has a member, so `== 1` below is a + // measured count rather than an accidental zero. + assert_eq!( + runner.state().objects[&loaned].controller, + P1, + "reach-guard: the counted permanent really is owned by P0 and controlled by P1" + ); + assert_eq!( + p1p1(&runner, ally), + 1, + "ungated 'that token' binds the created Ally (one permanent owned by P0 under P1's control)" + ); + assert_eq!( + p1p1(&runner, decoy), + 0, + "the stale decoy receives no counter" + ); +} + +/// T5 — negative control. North Pole Research Base's reflexive body is +/// "When you do, tap target nontoken creature an opponent controls. Put a stun +/// counter on it." — its gated clause is a `SetTapState`, NOT a token +/// publisher, so "it" must keep binding the TAPPED CREATURE even though an +/// Alien token was created earlier in the same chain. +#[test] +fn north_pole_stun_counter_stays_on_tapped_creature() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "North Pole Research Base"); + let victim = add_creature(&mut runner, P1, "Opponent Bear"); + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(NORTH_POLE, "North Pole Research Base", &["Land"], 1); + resolve_def(&mut runner, &def, source, vec![]); + select_targets_and_resolve(&mut runner, vec![TargetRef::Object(victim)]); + + let alien = token_named(&runner, decoy, "Alien"); + // Reach-guards: the token publisher DID run and the reflexive body DID tap. + assert!( + runner.state().objects[&alien].is_token, + "reach-guard: the Alien token exists, so a LastCreated mis-bind had a live target" + ); + assert!( + runner.state().objects[&victim].tapped, + "reach-guard: the reflexive body really resolved (the target is tapped)" + ); + + let stun = CounterType::Stun; + assert_eq!( + counter(&runner, victim, &stun), + 1, + "'it' after a SetTapState binds the tapped creature (CR 608.2c)" + ); + assert_eq!( + counter(&runner, alien, &stun), + 0, + "the Alien token created earlier in the chain receives NO stun counter" + ); +} + +/// T6 — no-regression control. Ratonhnhaké꞉ton's reflexive body returns an +/// Equipment card and attaches it to "that token": the NON-counter consumer of +/// a gated publisher's referent must still bind the new Assassin token. +#[test] +fn ratonhnhaketon_attach_still_binds_created_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Ratonhnhak\u{e9}\u{a789}ton"); + let equipment = add_object(&mut runner, P0, "Bone Saw", Zone::Graveyard); + { + let obj = runner.state_mut().objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Equipment".to_string()); + obj.base_card_types = obj.card_types.clone(); + } + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger( + RATONHNHAKETON, + "Ratonhnhak\u{e9}\u{a789}ton", + &["Creature"], + 0, + ); + resolve_def(&mut runner, &def, source, vec![]); + select_targets_and_resolve(&mut runner, vec![TargetRef::Object(equipment)]); + + let assassin = token_named(&runner, decoy, "Assassin"); + // Reach-guard: the antecedent return really happened, so the attach clause + // had something to attach. + assert_eq!( + runner.state().objects[&equipment].zone, + Zone::Battlefield, + "reach-guard: the Equipment really returned from the graveyard" + ); + assert_eq!( + runner.state().objects[&equipment].attached_to, + Some(AttachTarget::Object(assassin)), + "the returned Equipment attaches to the NEWLY created Assassin token" + ); + assert_ne!( + runner.state().objects[&equipment].attached_to, + Some(AttachTarget::Object(decoy)), + "and never to the stale decoy" + ); +} + +/// T7 — hostile: DECLINE. With the reflexive gate declined no token is created, +/// so the consumer must place nothing at all — in particular not on the decoy, +/// which is still the only entry in `last_created_token_ids`. +#[test] +fn declined_reflexive_body_places_no_counter_on_stale_token() { + let mut runner = GameScenario::new().build(); + let source = add_creature(&mut runner, P0, "Iroh, Tea Master"); + let bear = add_creature(&mut runner, P0, "Bear"); + let decoy = pre_seed_decoy(&mut runner); + + let def = parse_trigger(IROH, "Iroh, Tea Master", &["Creature"], 1); + resolve_def( + &mut runner, + &def, + source, + vec![TargetRef::Object(bear), TargetRef::Player(P1)], + ); + assert!( + decide(&mut runner, false), + "the reflexive gate was DECLINED" + ); + + assert_eq!( + live_tokens(&runner, decoy), + Vec::::new(), + "reach-guard: declining really created no token" + ); + assert_eq!( + runner.state().last_created_token_ids, + vec![decoy], + "reach-guard: the decoy is still the only LastCreated candidate" + ); + assert_eq!( + p1p1(&runner, decoy), + 0, + "the stale decoy receives NO counter on the declined path" + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T8–T17 — the gate-FALSE hazard, the shipped consumers, and the anti-over-skip +// controls. +// ═════════════════════════════════════════════════════════════════════════════ + +/// Drive one synthetic trigger fixture with a pre-seeded decoy, optionally +/// funded, optionally with the re-link reverted in-test. +struct Driven { + runner: GameRunner, + decoy: ObjectId, + dlife: i32, +} + +fn drive_synth(text: &str, funded: bool, revert: bool) -> Driven { + let mut scenario = GameScenario::new(); + fund(&mut scenario, funded); + let mut runner = scenario.build(); + let source = add_creature(&mut runner, P0, "Synth"); + let decoy = pre_seed_decoy(&mut runner); + // Draw fuel, so a "Draw a card" reach-guard is measurable. + add_object(&mut runner, P0, "Library Filler", Zone::Library); + let life0 = runner.state().players[0].life; + + let mut def = parse_trigger(text, "Synth", &["Creature"], 0); + if revert { + unrelink(&mut def); + } + resolve_def(&mut runner, &def, source, vec![]); + decide(&mut runner, true); + + let dlife = runner.state().players[0].life - life0; + Driven { + runner, + decoy, + dlife, + } +} + +/// T8 — THE HAZARD. Accept "you may pay {2}" with zero mana: `pay.rs` sets +/// `cost_payment_failed_flag`, the reflexive gate evaluates FALSE, and the +/// resolver's condition-false descent would resolve the counter clause with no +/// token created. Both connectors are measured — `If you do` and `When you do` +/// are NOT interchangeable at the resolver. +/// +/// FLIPS: dropping the whole §7.3 re-link pass (or the in-test `unrelink`) +/// makes the decoy's `+1/+1` go 0 → 1 on BOTH gates. +#[test] +fn reflexive_gate_false_on_accept_places_no_counter_on_stale_token() { + for (label, text) in [("if you do", BASE_IFYOUDO), ("when you do", BASE_WHENYOUDO)] { + let d = drive_synth(text, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "[{label}] reach-guard: the payment really failed, so the gate really went FALSE" + ); + assert_eq!( + live_tokens(&d.runner, d.decoy), + Vec::::new(), + "[{label}] reach-guard: no token was created on the false gate" + ); + assert_eq!( + p1p1(&d.runner, d.decoy), + 0, + "[{label}] the stale decoy receives NO counter" + ); + } +} + +/// T9 — shipped `IfYouDo` consumer: Akoum Stonewaker's "Exile that token at the +/// beginning of the next end step." On a failed payment no delayed trigger may +/// be created at all, and none may name the decoy. +/// +/// FLIPS: the in-test `unrelink` revert-probe makes a delayed `ChangeZone` +/// appear TARGETING THE DECOY. +#[test] +fn akoum_stonewaker_gate_false_creates_no_delayed_trigger_on_stale_token() { + let d = drive_ship(AKOUM, 0, false, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: {{2}}{{R}} could not be paid, so the IfYouDo gate went FALSE" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "no delayed trigger targets the stale decoy" + ); + assert!( + d.runner.state().delayed_triggers.is_empty(), + "and no delayed trigger was created at all" + ); +} + +/// T10 — shipped cascade consumer: Cadric, Soul Kindler's "That token gains +/// haste. Sacrifice it at the beginning of the next end step." Both legs of the +/// cascade must be skipped on a false gate. +/// +/// FLIPS: the in-test `unrelink` makes a delayed `Sacrifice` target the decoy. +#[test] +fn cadric_gate_false_skips_the_whole_cascade() { + let d = drive_ship(CADRIC, 0, false, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: {{1}} could not be paid, so the IfYouDo gate went FALSE" + ); + assert_eq!( + live_tokens(&d.runner, d.decoy), + Vec::::new(), + "reach-guard: no copy token was created" + ); + assert_eq!( + d.runner.state().objects[&d.decoy].keywords.len(), + 0, + "leg 1: the decoy gained no haste" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "leg 2: no delayed Sacrifice targets the decoy" + ); +} + +/// T11 — true-path parity. With the payment funded, all three shipped +/// consumers behave IDENTICALLY with and without the re-link: the token is +/// created and every consumer binds the NEW token. +#[test] +fn akoum_and_cadric_and_rebellion_gate_true_are_unchanged() { + for revert in [false, true] { + // Akoum: the delayed ChangeZone names the new Elemental. + let d = drive_ship(AKOUM, 0, true, revert, false); + let elemental = token_named(&d.runner, d.decoy, "Elemental"); + assert!( + delayed_targets(&d.runner, elemental), + "[revert={revert}] Akoum's delayed exile names the NEW token" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "[revert={revert}] and never the decoy" + ); + + // Cadric: the copy token exists WITH haste, and the delayed Sacrifice + // names it. + let d = drive_ship(CADRIC, 0, true, revert, false); + let copy = *live_tokens(&d.runner, d.decoy) + .first() + .expect("Cadric's copy token exists"); + assert_eq!( + d.runner.state().objects[©].keywords.len(), + 1, + "[revert={revert}] Cadric's copy token gained haste" + ); + assert!( + delayed_targets(&d.runner, copy), + "[revert={revert}] Cadric's delayed Sacrifice names the NEW token" + ); + + // Rebellion: the Elemental Shaman is created (its own EventOutcomeWon + // tail is still evaluated). + let d = drive_ship(REBELLION, 0, true, revert, false); + token_named(&d.runner, d.decoy, "Elemental Shaman"); + } +} + +/// T12 — anti-over-skip #1. Springheart Nantuko's templating: the tail carries +/// its OWN `Not{OptionalEffectPerformed}` complement condition, so an accept +/// with zero mana must STILL create the Insect. A blanket "skip the whole +/// reflexive descent" remedy fails here — this is the test that would have +/// caught the rejected round-2 design. +#[test] +fn reflexive_complement_branch_still_resolves_on_failed_payment() { + let d = drive_synth(COMPLEMENT, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: the payment failed, so the complement branch is the live one" + ); + let insect = token_named(&d.runner, d.decoy, "Insect"); + assert!( + d.runner.state().objects[&insect].is_token, + "the 'If you didn't create a token this way' branch still resolves" + ); +} + +/// T13 — anti-over-skip #2. The token is created UNGATED and the false gate +/// sits on a NON-publisher clause ("If you do, draw a card"), so the live Ally +/// must still get its counter. +/// +/// FLIPS: dropping the §7.3 publisher lookup (re-linking after ANY prior gated +/// clause) re-tags the tail `SequentialSibling → ContinuationStep` and the +/// live Ally's `+1/+1` goes 1 → 0. +#[test] +fn ungated_token_keeps_live_referent_across_a_false_gate() { + let d = drive_synth(INTERVENING_UNGATED, false, false); + let ally = token_named(&d.runner, d.decoy, "Ally"); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: the intervening {{2}} really failed, so a false gate is on the path" + ); + assert_eq!( + p1p1(&d.runner, ally), + 1, + "the UNGATED publisher's token still receives its counter" + ); + assert_eq!( + p1p1(&d.runner, d.decoy), + 0, + "and the stale decoy receives none" + ); +} + +/// T14 — two-publisher hostile pair. The NEAREST preceding publisher decides, +/// not "any gated publisher anywhere in the chain". +/// +/// Behavioural: gated-last places nothing (the Insect was never created); +/// ungated-last still places on the live Ally. +/// +/// AST-SHAPE (load-bearing): on ungated-last the consumer's `sub_link` STAYS +/// `SequentialSibling`. FLIPS: dropping the publisher-gate conjunct re-tags it +/// `ContinuationStep`. The drive rows are unchanged by that drop — the descent +/// resolves the ungated publisher's own chain either way — so the AST row is +/// what discriminates the conjunct, and is labelled as such. +#[test] +fn nearest_publisher_decides_the_relink() { + let gated = drive_synth(TWOPUB_GATED_LAST, false, false); + let ally = token_named(&gated.runner, gated.decoy, "Ally"); + assert!( + gated.runner.state().cost_payment_failed_flag, + "reach-guard: the {{2}} failed, so the nearest (Insect) publisher is gate-FALSE" + ); + assert_eq!( + p1p1(&gated.runner, ally), + 0, + "gated-last: the consumer belongs to the ungated Ally's SUCCESSOR gate and places nothing" + ); + assert_eq!( + p1p1(&gated.runner, gated.decoy), + 0, + "gated-last: and nothing on the stale decoy" + ); + + let ungated = drive_synth(TWOPUB_UNGATED_LAST, false, false); + let ally = token_named(&ungated.runner, ungated.decoy, "Ally"); + assert_eq!( + p1p1(&ungated.runner, ally), + 1, + "ungated-last: the nearest publisher is UNGATED, so the live Ally gets its counter" + ); + assert_eq!( + p1p1(&ungated.runner, ungated.decoy), + 0, + "ungated-last: and nothing on the stale decoy" + ); + + // AST-SHAPE row. + let def = parse_trigger(TWOPUB_UNGATED_LAST, "Synth", &["Creature"], 0); + let consumer = "sss"; + assert!( + matches!(target_at(&def, consumer), TargetFilter::LastCreated), + "reach-guard: the consumer really is the LastCreated reader" + ); + assert_eq!( + sub_link_at(&def, consumer), + SubAbilityLink::SequentialSibling, + "an UNGATED nearest publisher must NOT be re-linked (CR 608.2c: still the next \ + independent instruction)" + ); +} + +/// T15 — an independent instruction between the gated publisher and a +/// DELAYED-TRIGGER consumer blocks the re-link. AST-SHAPE row: the consumer's +/// `sub_link` stays `SequentialSibling`, because re-tagging it would make it a +/// continuation step of the intervening sibling — the descent selects that +/// sibling and resolves the consumer anyway, so the conjunct's job is to stop +/// `sub_link` from LYING, not to change behaviour. +/// +/// FLIPS: dropping `gated_instruction_reaches` re-tags the +/// `CreateDelayedTrigger` consumer to `ContinuationStep`. +#[test] +fn intervening_instruction_blocks_the_relink() { + let d = drive_synth(GAP1_DELAYED, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: the payment failed, so the gate went FALSE" + ); + assert_eq!( + d.dlife, 1, + "reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + + let def = parse_trigger(GAP1_DELAYED, "Synth", &["Creature"], 0); + let mut delayed_links = Vec::new(); + walk(&def, &mut |node| { + if matches!(&*node.effect, Effect::CreateDelayedTrigger { .. }) { + delayed_links.push((node.sub_link, effect_reads_last_created(node))); + } + }); + assert_eq!( + delayed_links.len(), + 1, + "reach-guard: exactly one CreateDelayedTrigger consumer was parsed" + ); + assert!( + delayed_links[0].1, + "reach-guard: that consumer really reads LastCreated" + ); + assert_eq!( + delayed_links[0].0, + SubAbilityLink::SequentialSibling, + "an intervening independent instruction blocks the re-link (CR 608.2c)" + ); +} + +/// T16 — shipped `WhenYouDo` consumer on the FALSE path: Saheeli, Radiant +/// Creator's "Sacrifice it at the beginning of the next end step." with zero +/// energy. `fund` grants energy only when funded, which is what lets this gate +/// go false at all. +/// +/// FLIPS: the in-test `unrelink` makes a delayed `Sacrifice` target the decoy. +#[test] +fn saheeli_when_you_do_gate_false_creates_no_delayed_trigger_on_stale_token() { + let d = drive_ship(SAHEELI, 1, false, false, true); + assert!( + d.runner.state().cost_payment_failed_flag, + "reach-guard: {{E}}{{E}}{{E}} could not be paid with 0 energy, so WhenYouDo went FALSE" + ); + assert!( + !delayed_targets(&d.runner, d.decoy), + "no delayed Sacrifice targets the stale decoy" + ); + assert!( + d.runner.state().delayed_triggers.is_empty(), + "and no delayed trigger was created at all" + ); +} + +/// T17 — round-5's seeder-side conjunct: an independent instruction between the +/// gated publisher and a COUNTER consumer blocks the SEED, so the consumer never +/// acquires a `LastCreated` target in the first place. +/// +/// FLIPS: dropping the seeder's reach conjunct puts the decoy's `+1/+1` at +/// 0 → 1 on BOTH connectors. +#[test] +fn intervening_instruction_blocks_the_seed() { + for (label, text) in [("if you do", GAP1_IFYOUDO), ("when you do", GAP1_WHENYOUDO)] { + let d = drive_synth(text, false, false); + assert!( + d.runner.state().cost_payment_failed_flag, + "[{label}] reach-guard: the payment failed, so the gate went FALSE" + ); + assert_eq!( + d.dlife, 1, + "[{label}] reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + assert_eq!( + p1p1(&d.runner, d.decoy), + 0, + "[{label}] the stale decoy receives NO counter" + ); + + // The seeder-side property `sub_link` alone cannot see: the PutCounter + // never became a `LastCreated` reader. + let def = parse_trigger(text, "Synth", &["Creature"], 0); + let mut put_targets = Vec::new(); + walk(&def, &mut |node| { + if matches!(&*node.effect, Effect::PutCounter { .. }) { + put_targets.push(node.effect.target_filter().cloned()); + } + }); + assert_eq!( + put_targets.len(), + 1, + "[{label}] reach-guard: exactly one PutCounter consumer was parsed" + ); + assert!( + !matches!(put_targets[0], Some(TargetFilter::LastCreated)), + "[{label}] the blocked seed leaves the PutCounter off LastCreated (got {:?})", + put_targets[0] + ); + } +} + +/// Shared shipped-card drive. `trig` selects the trigger, `targeted` supplies a +/// real object target (Saheeli's `CopyTokenOf`), `revert` applies the in-test +/// `unrelink` revert-probe. +fn drive_ship(text: &str, trig: usize, funded: bool, revert: bool, targeted: bool) -> Driven { + let mut scenario = GameScenario::new(); + fund(&mut scenario, funded); + let mut runner = scenario.build(); + let source = add_creature(&mut runner, P0, "Src"); + let decoy = pre_seed_decoy(&mut runner); + let victim = add_creature(&mut runner, P0, "Victim"); + if funded { + runner.state_mut().players[0].energy = 3; + } + // Give `CopyTokenOf { TriggeringSource }` a real antecedent object. + runner.state_mut().current_trigger_event = + Some(engine::types::events::GameEvent::PermanentUntapped { object_id: victim }); + let life0 = runner.state().players[0].life; + + let mut def = parse_trigger(text, "Src", &["Creature"], trig); + if revert { + unrelink(&mut def); + } + let targets = if targeted { + vec![TargetRef::Object(victim)] + } else { + vec![] + }; + resolve_def(&mut runner, &def, source, targets); + decide(&mut runner, true); + + let dlife = runner.state().players[0].life - life0; + Driven { + runner, + decoy, + dlife, + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T18 — the corpus-complete stale-bind invariant over the 20 cards whose AST +// this change moved. +// ═════════════════════════════════════════════════════════════════════════════ + +/// The 20 cards of the BASE↔POST AST diff over all 34348 corpus cards, embedded +/// as inline consts — name, Oracle text and `types` byte-verbatim from +/// `AtomicCards.json`. Hermetic on purpose: `data/mtgjson/AtomicCards.json` and +/// `client/public/card-data.json` are both gitignored and CI's `rust-test` job +/// is a bare checkout + `cargo nextest run`, so a corpus-file form of this test +/// could only be permanently red or permanently skipped. +const CHANGED: &[(&str, &str, &[&str])] = &[ + ( + "Akoum Stonewaker", + "Landfall — Whenever a land you control enters, you may pay {2}{R}. If you do, create a 3/1 red Elemental creature token with trample and haste. Exile that token at the beginning of the next end step.", + &["Creature"], + ), + ( + "Blight Herder", + "When you cast this spell, you may put two cards your opponents own from exile into their owners' graveyards. If you do, create three 1/1 colorless Eldrazi Scion creature tokens. They have \"Sacrifice this token: Add {C}.\"", + &["Creature"], + ), + ( + "Boulder Jockey", + "({D} is a land drop. You may give up one potential land drop this turn to pay for {D}.)\nWhenever Boulder Jockey attacks, you may pay {D}. If you do, create a 3/3 colorless Construct artifact creature token named Boulder that's tapped and attacking. Sacrifice that token at the beginning of the next end step.", + &["Creature"], + ), + ( + "Cadric, Soul Kindler", + "The \"legend rule\" doesn't apply to tokens you control.\nWhenever another nontoken legendary permanent you control enters, you may pay {1}. If you do, create a token that's a copy of it. That token gains haste. Sacrifice it at the beginning of the next end step.", + &["Creature"], + ), + ( + "Dalek Intensive Care", + "When you planeswalk to Dalek Intensive Care and at the beginning of your upkeep, exile a non-Dalek creature you control. If you do, create a 3/3 black Dalek artifact creature token with menace. It gains haste until end of turn.\nWhenever chaos ensues, target Dalek you control deals damage equal to its power to target creature you don't control.", + &["Plane"], + ), + ( + "Felhide Spiritbinder", + "Inspired — Whenever this creature becomes untapped, you may pay {1}{R}. If you do, create a token that's a copy of another target creature, except it's an enchantment in addition to its other types. It gains haste. Exile it at the beginning of the next end step.", + &["Creature"], + ), + ( + "Flameshadow Conjuring", + "Whenever a nontoken creature you control enters, you may pay {R}. If you do, create a token that's a copy of that creature. That token gains haste. Exile it at the beginning of the next end step.", + &["Enchantment"], + ), + ( + "God-Pharaoh's Gift", + "At the beginning of combat on your turn, you may exile a creature card from your graveyard. If you do, create a token that's a copy of that card, except it's a 4/4 black Zombie. It gains haste until end of turn.", + &["Artifact"], + ), + ( + "Gyrus, Waker of Corpses", + "Gyrus enters with a number of +1/+1 counters on it equal to the amount of mana spent to cast it.\nWhenever Gyrus attacks, you may exile target creature card with lesser power from your graveyard. If you do, create a token that's a copy of that card and that's tapped and attacking. Exile the token at end of combat.", + &["Creature"], + ), + ( + "Inalla, Archmage Ritualist", + "Eminence — Whenever another nontoken Wizard you control enters, if Inalla is in the command zone or on the battlefield, you may pay {1}. If you do, create a token that's a copy of that Wizard. The token gains haste. Exile it at the beginning of the next end step.\nTap five untapped Wizards you control: Target player loses 7 life.", + &["Creature"], + ), + ( + "Iroh, Tea Master", + "When Iroh enters, create a Food token.\nAt the beginning of combat on your turn, you may have target opponent gain control of target permanent you control. When you do, create a 1/1 white Ally creature token. Put a +1/+1 counter on that token for each permanent you own that your opponents control.", + &["Creature"], + ), + ( + "Kavaron Harrier", + "Whenever this creature attacks, you may pay {2}. If you do, create a 2/2 colorless Robot artifact creature token that's tapped and attacking. Sacrifice that token at end of combat.", + &["Artifact", "Creature"], + ), + ( + "Krenko, Baron of Tin Street", + "Haste\n{T}, Sacrifice an artifact: Put a +1/+1 counter on each Goblin you control.\nWhenever an artifact is put into a graveyard from the battlefield, you may pay {R}. If you do, create a 1/1 red Goblin creature token. It gains haste until end of turn.", + &["Creature"], + ), + ( + "Rebellion of the Flamekin", + "Whenever you clash, you may pay {1}. If you do, create a 3/1 red Elemental Shaman creature token. If you won, that token gains haste until end of turn. (This ability triggers after the clash ends.)", + &["Kindred", "Enchantment"], + ), + ( + "Saheeli, Radiant Creator", + "Whenever you cast an Artificer or artifact spell, you get {E} (an energy counter).\nAt the beginning of combat on your turn, you may pay {E}{E}{E}. When you do, create a token that's a copy of target permanent you control, except it's a 5/5 artifact creature in addition to its other types and has haste. Sacrifice it at the beginning of the next end step.", + &["Creature"], + ), + ( + "Séance", + "At the beginning of each upkeep, you may exile target creature card from your graveyard. If you do, create a token that's a copy of that card, except it's a Spirit in addition to its other types. Exile it at the beginning of the next end step.", + &["Enchantment"], + ), + ( + "Summoner's Sending", + "At the beginning of your end step, you may exile target creature card from a graveyard. If you do, create a 1/1 white Spirit creature token with flying. Put a +1/+1 counter on it if the exiled card's mana value is 4 or greater.", + &["Enchantment"], + ), + ( + "Timothar, Baron of Bats", + "Ward—Discard a card.\nWhenever another nontoken Vampire you control dies, you may pay {1} and exile it. If you do, create a 1/1 black Bat creature token with flying. It gains \"When this token deals combat damage to a player, sacrifice it and return the exiled card to the battlefield tapped.\"", + &["Creature"], + ), + ( + "Ultron, Artificial Malevolence", + "Whenever another nontoken artifact you control enters, you may pay {2}. If you do, create a token that's a copy of it. If the token isn't a creature, it becomes a 2/2 Robot Villain creature in addition to its other types.", + &["Artifact", "Creature"], + ), + ( + "Vile Redeemer", + "Devoid (This card has no color.)\nFlash\nWhen you cast this spell, you may pay {C}. If you do, create a 1/1 colorless Eldrazi Scion creature token for each nontoken creature that died under your control this turn. Those tokens have \"Sacrifice this token: Add {C}.\"", + &["Creature"], + ), +]; + +/// T18 — no node tagged `SequentialSibling` reads `TargetFilter::LastCreated`, +/// over the 20 cards whose AST this change moved. Such a node is resolved by the +/// condition-false descent against `state.last_created_token_ids`, a +/// game-lifetime ledger, so it would bind a token from an EARLIER resolution. +/// +/// SCOPE: a FROZEN list. Because a card acquires a new `LastCreated` bind from +/// this change only if its parsed AST changed, and the BASE↔POST diff over all +/// 34348 cards is exactly these 20, checking the invariant here checks it over +/// the corpus AS OF THIS CHANGE — it cannot see a card that acquires the shape +/// later. +/// +/// ONE exemption, and it is the same predicate the shipping guard uses: +/// `AbilityDefinition::is_self_gated_reflexive` (`else_ability.is_none()` and +/// condition `EffectOutcome{OptionalEffectPerformed}`). A self-gated node is +/// dropped by the descent's own false-condition skip wherever it sits, so it +/// cannot stale-bind. The test CALLS that method rather than re-deriving it, so +/// the guard and the invariant cannot drift. It is deliberately NOT widened to +/// every `is_affirmative_reflexive_gate` condition: a `WhenYouDo` node reading +/// `LastCreated` DOES stale-bind (its evaluator keys on the effect it rides, so +/// on a `PutCounter` it is a constant true and gates nothing), and T20's +/// `COMMA_GAP_WHENYOUDO` row carries that measurement. +/// +/// NON-VACUITY (asserted in the same test): the number of `ContinuationStep` +/// nodes that DO read `LastCreated` is > 0. On the shipped tree it is 25, and +/// `SequentialSibling` readers are 0; with the re-link reverted the two counts +/// swap sides. +#[test] +fn no_sequential_sibling_reads_last_created() { + let mut stale: Vec = Vec::new(); + let mut exempt = 0usize; + let mut continuation_readers = 0usize; + + for (name, text, types) in CHANGED { + for def in card_definitions(name, text, types) { + walk(&def, &mut |node| { + if !effect_reads_last_created(node) { + return; + } + if node.sub_link != SubAbilityLink::SequentialSibling { + continuation_readers += 1; + } else if node.is_self_gated_reflexive() { + // Benign: the descent's false-condition skip drops it. + exempt += 1; + } else { + stale.push(format!("{name}: {:?}", node.effect)); + } + }); + } + } + + assert_eq!( + CHANGED.len(), + 20, + "the frozen list is the 20-card BASE↔POST AST diff" + ); + // The invariant is asserted BEFORE the non-vacuity control on purpose: + // reverting the re-link moves every reader back to `SequentialSibling`, so + // both assertions would fire and the failure must name the stale nodes + // rather than report an empty continuation count. + assert!( + stale.is_empty(), + "stale LastCreated bind(s) on {} SequentialSibling node(s): {stale:#?}", + stale.len() + ); + assert!( + continuation_readers > 0, + "non-vacuity: no LastCreated reader was found at all, so the invariant checks nothing \ + (exempt={exempt})" + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// T19–T21 — the "Repeat this process for …" replication grammar. +// ═════════════════════════════════════════════════════════════════════════════ + +const REPEAT_HEAD: &str = + "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token"; + +/// The complete reachable join space of `try_parse_repeat_process_for_keywords` +/// (anchored on exactly two tags: "repeat this process for " / "do the same +/// for "), crossed with both directive spellings and with the antecedent placed +/// before and after the gated publisher. +const REPEAT_TAILS: &[(&str, &str)] = &[ + ("repeat_sentence", ". Put a flying counter on this creature. Repeat this process for first strike. Put a +1/+1 counter on that token."), + ("repeat_comma", ", put a flying counter on this creature, repeat this process for first strike. Put a +1/+1 counter on that token."), + ("all_comma", ", put a flying counter on this creature, repeat this process for first strike, put a +1/+1 counter on that token."), + ("repeat_then", ", put a flying counter on this creature, then repeat this process for first strike. Put a +1/+1 counter on that token."), + ("repeat_and", ", put a flying counter on this creature and repeat this process for first strike. Put a +1/+1 counter on that token."), + ("dosame_sentence", ". Put a flying counter on this creature. Do the same for first strike. Put a +1/+1 counter on that token."), + ("dosame_comma", ", put a flying counter on this creature, do the same for first strike. Put a +1/+1 counter on that token."), + ("dosame_then", ", put a flying counter on this creature, then do the same for first strike. Put a +1/+1 counter on that token."), + ("dosame_and", ", put a flying counter on this creature and do the same for first strike. Put a +1/+1 counter on that token."), +]; + +/// The DIVERGENCE fixtures: the keyword-counter antecedent sits BEFORE the +/// gated publisher, so the directive clause lands directly after the publisher +/// joined by a comma / "then" / "and" / sentence — the shapes where a clone +/// could land between a gated publisher and a `LastCreated` consumer. +const REPEAT_HOSTILE: &[(&str, &str)] = &[ + ("hostile_comma", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token, repeat this process for first strike. Put a +1/+1 counter on that token."), + ("hostile_then", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token, then repeat this process for first strike. Put a +1/+1 counter on that token."), + ("hostile_and", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token and repeat this process for first strike. Put a +1/+1 counter on that token."), + ("hostile_dosame_comma", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token, do the same for first strike. Put a +1/+1 counter on that token."), + ("hostile_sentence", "When this creature enters, put a flying counter on this creature. You may pay {2}. If you do, create a 1/1 white Ally creature token. Repeat this process for first strike. Put a +1/+1 counter on that token."), + ("and_min", "When this creature enters, put a flying counter on this creature and repeat this process for first strike."), + ("comma_min", "When this creature enters, put a flying counter on this creature, repeat this process for first strike."), + ("sentence_min", "When this creature enters, put a flying counter on this creature. Repeat this process for first strike."), + ("and_gated", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token and put a flying counter on this creature and repeat this process for first strike. Put a +1/+1 counter on that token."), +]; + +/// T20's fixture set, reused by T19 (its templates DO read `LastCreated`, which +/// the `this creature` fixtures above structurally cannot). +/// +/// (a) GATED-HAZARD + controls, (b) round-8 NO-HAZARD, (c) round-11 acceptance +/// and its paired negative. +const T20_CASES: &[(&str, &str, &[&str])] = &[ + // ── (a) the transplant hazard: template is its OWN sentence, so the clone's + // landing slot is off the gated instruction's continuation path. + ("hazard_seq", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("hazard_seq_whenyoudo", "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token. Put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + // Hazard via a LATER token creator: the clone's own nearest publisher is the + // Soldier, so the flying template's referent would be transplanted. + ("hazard_2ndpub", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token, then create a 1/1 white Soldier creature token. Repeat this process for first strike.", &["Creature"]), + // (a) controls. + ("adj", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token. Repeat this process for first strike.", &["Creature"]), + ("comma_selfref", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token, put a flying counter on this creature. Repeat this process for first strike.", &["Creature"]), + ("ungated_gap", "When this creature enters, create a 1/1 white Ally creature token. Put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + // ── (b) NO-HAZARD: no token, no LastCreated, no gated publisher anywhere. + ("r8_gap_selfref", "When this creature enters, put a flying counter on this creature. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("r8_gap2_selfref", "When this creature enters, put a flying counter on this creature. You gain 1 life. Draw a card. Repeat this process for first strike.", &["Creature"]), + ("r8_gap_selfref_dosame", "When this creature enters, put a flying counter on this creature. You gain 1 life. Do the same for first strike.", &["Creature"]), + ("r8_kathril_gap", "When Kathril enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. You gain 1 life. Repeat this process for first strike, deathtouch.", &["Creature"]), + ("r8_adj_selfref", "When this creature enters, put a flying counter on this creature. Repeat this process for first strike.", &["Creature"]), + // ── (c) round-11 acceptance: the landing slot IS reached. + ("cont_gap", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token, then you gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("cont_gap_2kw", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token. Put a flying counter on that token, then you gain 1 life. Repeat this process for first strike and deathtouch.", &["Creature"]), + // ── (c) self-gating leg, and its PAIRED NEGATIVE one connector word apart. + ("comma_gap_ifyoudo", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token, put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), + ("comma_gap_dosame", "When this creature enters, you may pay {2}. If you do, create a 1/1 white Ally creature token, put a flying counter on that token. You gain 1 life. Do the same for first strike.", &["Creature"]), + ("comma_gap_whenyoudo", "When this creature enters, you may pay {2}. When you do, create a 1/1 white Ally creature token, put a flying counter on that token. You gain 1 life. Repeat this process for first strike.", &["Creature"]), +]; + +/// A clone node: a `PutCounter` placing one of the REPLICATED keyword counters +/// (the templates in these fixtures all place `flying`). Typed, never a +/// `blob.contains("first strike")` substring flag — that flag false-positives +/// on the bare-`and` rows, where the phrase survives inside an `Unimplemented` +/// description. +fn is_clone_node(def: &AbilityDefinition) -> bool { + matches!( + &*def.effect, + Effect::PutCounter { + counter_type: CounterType::Keyword(KeywordKind::FirstStrike | KeywordKind::Deathtouch), + .. + } + ) +} + +fn counter_types_of(def: &AbilityDefinition) -> Vec { + let mut out = Vec::new(); + walk(def, &mut |node| { + if let Effect::PutCounter { counter_type, .. } = &*node.effect { + out.push(counter_type.clone()); + } + }); + out +} + +/// T19 — L1 tripwire over the replication grammar's OWN complete join space. +/// +/// The invariant: no replicated clone node reads `LastCreated` from a landing +/// slot the resolver can reach without that token — i.e. no clone is both a +/// `SequentialSibling` `LastCreated` reader and not self-gated. Measured 0 on +/// the shipped tree. +/// +/// Round 6's original XOR form (`clone_present XOR consumer_binds_LastCreated`) +/// was VACUOUS with respect to its own purpose: all 18 `this creature` fixtures +/// are structurally incapable of carrying a `LastCreated` template, so both +/// conjuncts were true on the counterexample. T20's fixture set — whose +/// templates DO read `LastCreated` — is therefore carried here too, and the XOR +/// is replaced by the direct invariant. +/// +/// SCOPE: the invariant is conditioned on "its nearest GATED publisher is +/// unreachable", so a fixture that carries no affirmative reflexive gate at all +/// (`ungated_gap`) is excluded — its publisher creates its token +/// unconditionally, so a `SequentialSibling` `LastCreated` clone there is BASE +/// behaviour and cannot stale-bind. The exclusion is per FIXTURE, not per +/// publisher, which is the conservative direction: a fixture with a gate +/// somewhere but an ungated nearest publisher is still checked, so this can +/// over-report but never under-report. +/// +/// NON-VACUITY (asserted): at least one fixture materialises a clone at all, +/// and at least one clone node reads `LastCreated`, so both arms are witnessed. +#[test] +fn repeat_process_directive_never_joins_a_continuation_path() { + let joined: Vec<(String, String, Vec<&str>)> = REPEAT_TAILS + .iter() + .map(|(l, t)| { + ( + (*l).to_string(), + format!("{REPEAT_HEAD}{t}"), + vec!["Creature"], + ) + }) + .chain( + REPEAT_HOSTILE + .iter() + .map(|(l, t)| ((*l).to_string(), (*t).to_string(), vec!["Creature"])), + ) + .chain( + T20_CASES + .iter() + .map(|(l, t, ty)| ((*l).to_string(), (*t).to_string(), ty.to_vec())), + ) + .collect(); + + let mut clones_seen = 0usize; + let mut clone_last_created_readers = 0usize; + let mut ungated_fixtures = 0usize; + let mut violations: Vec = Vec::new(); + + for (label, text, types) in &joined { + let defs = card_definitions("Repeat Probe", text, types); + let mut has_gate = false; + for def in &defs { + walk(def, &mut |node| { + has_gate |= node + .condition + .as_ref() + .is_some_and(|c| c.is_affirmative_reflexive_gate()); + }); + } + if !has_gate { + ungated_fixtures += 1; + continue; + } + for def in defs { + walk(&def, &mut |node| { + if !is_clone_node(node) { + return; + } + clones_seen += 1; + if !effect_reads_last_created(node) { + return; + } + clone_last_created_readers += 1; + if node.sub_link == SubAbilityLink::SequentialSibling + && !node.is_self_gated_reflexive() + { + violations.push(format!("{label}: {:?}", node.effect)); + } + }); + } + } + + assert!( + clones_seen > 0, + "non-vacuity: no replicated keyword counter materialised in ANY fixture" + ); + assert!( + clone_last_created_readers > 0, + "non-vacuity: no clone read LastCreated, so the invariant checks nothing" + ); + assert!( + ungated_fixtures > 0 && ungated_fixtures < joined.len(), + "the gate-presence filter is neither empty nor total (excluded {ungated_fixtures} of {})", + joined.len() + ); + assert!( + violations.is_empty(), + "a replicated clone landed off the gated instruction's continuation path while reading \ + LastCreated: {violations:#?}" + ); +} + +/// One driven row of the T20 matrix. +struct CloneRow { + runner: GameRunner, + decoy: ObjectId, + dlife: i32, + dhand: i64, + live: Vec, +} + +fn drive_clone(text: &str, types: &[&str], funded: bool) -> CloneRow { + let mut scenario = GameScenario::new(); + fund(&mut scenario, funded); + let mut runner = scenario.build(); + let source = add_creature(&mut runner, P0, "Synth"); + let decoy = pre_seed_decoy(&mut runner); + add_object(&mut runner, P0, "Library Filler", Zone::Library); + let life0 = runner.state().players[0].life; + let hand0 = runner.state().players[0].hand.len(); + + let def = parse_trigger(text, "Synth", types, 0); + resolve_def(&mut runner, &def, source, vec![]); + decide(&mut runner, true); + + let live = live_tokens(&runner, decoy); + CloneRow { + dlife: runner.state().players[0].life - life0, + dhand: runner.state().players[0].hand.len() as i64 - hand0 as i64, + runner, + decoy, + live, + } +} + +fn case_text(label: &str) -> (&'static str, &'static [&'static str]) { + T20_CASES + .iter() + .find(|(l, _, _)| *l == label) + .map(|(_, t, ty)| (*t, *ty)) + .unwrap_or_else(|| panic!("no T20 fixture named {label:?}")) +} + +/// T20 — a "Repeat this process for …" clone must never transplant a gated +/// publisher's just-created-token referent to a slot the resolver reaches +/// without that token, and must never silently DROP a printed replication that +/// is honest where it lands. +/// +/// (a) GATED-HAZARD, gate FALSE: the decoy gets no `Keyword(FirstStrike)` +/// counter, reach-guarded by `cost_payment_failed_flag` and "no live token +/// was created". +/// (b) NO-HAZARD (no token, no `LastCreated`, no gated publisher): every printed +/// replication is still emitted. +/// (c) LANDING-SLOT REACHED / SELF-GATED, gate TRUE: the live token gets BOTH +/// counters — paired with the negative `comma_gap_whenyoudo`, which gets only +/// `flying`, one connector word apart. +#[test] +fn repeat_process_clone_does_not_transplant_a_gated_referent() { + // ── (a) the hazard rows, gate FALSE. + for label in [ + "hazard_seq", + "hazard_seq_whenyoudo", + "hazard_2ndpub", + "comma_gap_dosame", + "adj", + "comma_selfref", + ] { + let (text, types) = case_text(label); + let r = drive_clone(text, types, false); + assert!( + r.runner.state().cost_payment_failed_flag, + "[{label}] reach-guard: the payment failed, so the gate really went FALSE" + ); + assert_eq!( + r.live, + Vec::::new(), + "[{label}] reach-guard: no token was created on the false gate" + ); + assert_eq!( + kw_counter(&r.runner, r.decoy, KeywordKind::FirstStrike), + 0, + "[{label}] the stale decoy receives NO replicated first-strike counter" + ); + assert_eq!( + kw_counter(&r.runner, r.decoy, KeywordKind::Flying), + 0, + "[{label}] nor the template's own flying counter" + ); + } + // The `*_gap` hazard rows carry an independent intervening instruction, so + // its resolution is an extra reach-guard. + for label in ["hazard_seq", "hazard_seq_whenyoudo"] { + let (text, types) = case_text(label); + assert_eq!( + drive_clone(text, types, false).dlife, + 1, + "[{label}] reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + } + + // ── (a) ungated control: BASE behaviour, benign — the live token keeps BOTH + // printed counters because its publisher is unconditional. + { + let (text, types) = case_text("ungated_gap"); + let r = drive_clone(text, types, false); + let ally = token_named(&r.runner, r.decoy, "Ally"); + assert_eq!( + ( + kw_counter(&r.runner, ally, KeywordKind::Flying), + kw_counter(&r.runner, ally, KeywordKind::FirstStrike), + ), + (1, 1), + "ungated_gap: an UNGATED publisher's replication is never dropped" + ); + assert_eq!( + kw_counter(&r.runner, r.decoy, KeywordKind::FirstStrike), + 0, + "ungated_gap: and nothing lands on the stale decoy" + ); + } + + // ── (b) NO-HAZARD acceptance: every printed replication is emitted. + for label in [ + "r8_gap_selfref", + "r8_gap2_selfref", + "r8_gap_selfref_dosame", + "r8_adj_selfref", + ] { + let (text, types) = case_text(label); + let r = drive_clone(text, types, false); + let source = *r + .runner + .state() + .battlefield + .iter() + .find(|id| r.runner.state().objects[id].name == "Synth") + .expect("the source is on the battlefield"); + assert_eq!( + ( + kw_counter(&r.runner, source, KeywordKind::Flying), + kw_counter(&r.runner, source, KeywordKind::FirstStrike), + ), + (1, 1), + "[{label}] both the template's and the replicated counter are placed" + ); + if label != "r8_adj_selfref" { + assert_eq!( + r.dlife, 1, + "[{label}] reach-guard: the intervening 'You gain 1 life.' really resolved" + ); + } + if label == "r8_gap2_selfref" { + assert_eq!( + r.dhand, 1, + "[{label}] reach-guard: the second intervening 'Draw a card.' really resolved" + ); + } + } + // Kathril's own templating: its gate needs a graveyard, so this row is + // asserted on the parsed replication, with the intervening clause's + // resolution as the reach-guard. + { + let (text, types) = case_text("r8_kathril_gap"); + let r = drive_clone(text, types, false); + assert_eq!( + r.dlife, 1, + "r8_kathril_gap: reach-guard: the intervening clause really resolved" + ); + let kinds: Vec = card_definitions("Kathril Gap", text, types) + .iter() + .flat_map(counter_types_of) + .collect(); + for k in [ + KeywordKind::Flying, + KeywordKind::FirstStrike, + KeywordKind::Deathtouch, + ] { + assert!( + kinds.contains(&CounterType::Keyword(k)), + "r8_kathril_gap: the printed {k:?} replication is not dropped (got {kinds:?})" + ); + } + } + + // ── (c) landing slot reached / self-gated, gate TRUE. + for (label, extra) in [ + ("cont_gap", None), + ("cont_gap_2kw", Some(KeywordKind::Deathtouch)), + ("comma_gap_ifyoudo", None), + ("comma_gap_dosame", None), + ] { + let (text, types) = case_text(label); + let r = drive_clone(text, types, true); + let ally = token_named(&r.runner, r.decoy, "Ally"); + assert_eq!( + ( + kw_counter(&r.runner, ally, KeywordKind::Flying), + kw_counter(&r.runner, ally, KeywordKind::FirstStrike), + ), + (1, 1), + "[{label}] the live token receives the template AND the replicated counter" + ); + if let Some(k) = extra { + assert_eq!( + kw_counter(&r.runner, ally, k), + 1, + "[{label}] and the second listed keyword" + ); + } + } + + // ── (c) THE PAIRED NEGATIVE, one connector word apart: `When you do` is not + // position-independent (its evaluator keys on the effect it rides), so the + // clone must NOT be emitted — and on the false path the decoy stays clean. + { + let (text, types) = case_text("comma_gap_whenyoudo"); + let r = drive_clone(text, types, true); + let ally = token_named(&r.runner, r.decoy, "Ally"); + assert_eq!( + kw_counter(&r.runner, ally, KeywordKind::Flying), + 1, + "comma_gap_whenyoudo: reach-guard: the gate went TRUE and the template resolved" + ); + assert_eq!( + kw_counter(&r.runner, ally, KeywordKind::FirstStrike), + 0, + "comma_gap_whenyoudo: a WhenYouDo-gated clone is NOT position-independent, so the \ + replication is declined rather than transplanted" + ); + let f = drive_clone(text, types, false); + assert!( + f.runner.state().cost_payment_failed_flag, + "comma_gap_whenyoudo: reach-guard: the false arm's payment really failed" + ); + assert_eq!( + kw_counter(&f.runner, f.decoy, KeywordKind::FirstStrike), + 0, + "comma_gap_whenyoudo: and the stale decoy never receives the replicated counter" + ); + } + + // ── AST-shape: the only `SequentialSibling` `LastCreated` readers in the + // whole fixture set are `ungated_gap`'s (BASE behaviour, benign — its + // publisher is unconditional) and the SELF-GATED clones. + let mut ungated_seq = 0usize; + let mut selfgated_seq = 0usize; + for (label, text, types) in T20_CASES { + for def in card_definitions("T20", text, types) { + walk(&def, &mut |node| { + if node.sub_link != SubAbilityLink::SequentialSibling + || !effect_reads_last_created(node) + { + return; + } + if *label == "ungated_gap" { + ungated_seq += 1; + } else if node.is_self_gated_reflexive() { + selfgated_seq += 1; + } else { + panic!( + "[{label}] stale SequentialSibling LastCreated reader: {:?}", + node.effect + ); + } + }); + } + } + assert_eq!( + ungated_seq, 2, + "ungated_gap keeps BOTH its template and its clone as SequentialSibling LastCreated \ + readers (benign: the publisher is unconditional)" + ); + assert_eq!( + selfgated_seq, 2, + "exactly the two self-gated clones (comma_gap_ifyoudo, comma_gap_dosame) are exempt" + ); +} + +/// The complete set of cards whose Oracle text contains "repeat this process +/// for " or "do the same for " — corpus-complete by construction, since +/// `try_parse_repeat_process_for_keywords` is anchored on exactly those two +/// tags and is the sole producer of `ReplicateKind::CounterPlacement`. +/// Embedded verbatim from `AtomicCards.json`, like T18's fixtures. +const REPEAT_PROCESS_CORPUS: &[(&str, &str, &[&str])] = &[ + ( + "Equipoise", + "At the beginning of your upkeep, for each land target player controls in excess of the number you control, choose a land that player controls, then the chosen permanents phase out. Repeat this process for artifacts and creatures. (While they're phased out, they're treated as though they don't exist. They phase in before that player untaps during their next untap step.)", + &["Enchantment"], + ), + ( + "Estrid, the Masked", + "[+2]: Untap each enchanted permanent you control.\n[\u{2212}1]: Create a white Aura enchantment token named Mask attached to another target permanent. The token has enchant permanent and umbra armor.\n[\u{2212}7]: Mill seven cards. Return all non-Aura enchantment cards from your graveyard to the battlefield, then do the same for Aura cards.\nEstrid, the Masked can be your commander.", + &["Planeswalker"], + ), + ( + "Estrid, the Masked // Estrid, the Masked", + "[+2]: Untap each enchanted permanent you control.\n[\u{2212}1]: Create a white Aura enchantment token named Mask attached to another target permanent. The token has enchant permanent and umbra armor.\n[\u{2212}7]: Mill seven cards. Return all non-Aura enchantment cards from your graveyard to the battlefield, then do the same for Aura cards.\nEstrid, the Masked can be your commander.", + &["Planeswalker"], + ), + ( + "Firemind's Foresight", + "Search your library for an instant card with mana value 3, reveal it, and put it into your hand. Then repeat this process for instant cards with mana values 2 and 1. Then shuffle.", + &["Instant"], + ), + ( + "Glimpse of Tomorrow", + "Suspend 3\u{2014}{R}{R}\nShuffle all permanents you own into your library, then reveal that many cards from the top of your library. Put all non-Aura permanent cards revealed this way onto the battlefield, then do the same for Aura cards, then put the rest on the bottom of your library in a random order.", + &["Sorcery"], + ), + ( + "Grim Captain's Call", + "Return a Pirate card from your graveyard to your hand, then do the same for Vampire, Dinosaur, and Merfolk.", + &["Sorcery"], + ), + ( + "Gruesome Menagerie", + "Choose a creature card with mana value 1 in your graveyard, then do the same for creature cards with mana value 2 and 3. Return those cards to the battlefield.", + &["Sorcery"], + ), + ( + "Invoke Despair", + "Target opponent sacrifices a creature of their choice. If they can't, they lose 2 life and you draw a card. Then repeat this process for an enchantment and a planeswalker.", + &["Sorcery"], + ), + ( + "Kathril, Aspect Warper", + "When Kathril enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. Then put a +1/+1 counter on Kathril for each counter put on a creature this way.", + &["Creature"], + ), + ( + "Queen Kayla bin-Kroog", + "{4}, {T}: Discard all the cards in your hand, then draw that many cards. You may choose an artifact or creature card with mana value 1 you discarded this way, then do the same for artifact or creature cards with mana values 2 and 3. Return those cards to the battlefield. Activate only as a sorcery.", + &["Creature"], + ), + ( + "Runed Terror", + "Instead of taking turns as normal, players take their phases sequentially. (For example, you take your beginning phase as the active player, then the next player in turn order becomes the active player and takes their beginning phase. After each player had a beginning phase, do the same for first main, combat phase, second main, ending phase, and then beginning phase again. If this creature leaves the battlefield, the active player continues their turn as normal.)", + &["Artifact", "Creature"], + ), + ( + "Super-Adaptoid", + "Super-Adaptoid's power is equal to the number of legendary creatures you control.\nWhenever Super-Adaptoid enters or attacks, choose another target creature. If that creature has haste and Super-Adaptoid doesn't, put a haste counter on Super-Adaptoid. Do the same for flying, first strike, double strike, deathtouch, indestructible, lifelink, menace, reach, trample, and vigilance.", + &["Artifact", "Creature"], + ), +]; + +/// T21 — the shipped-card replication pin (round-8's restatement of the +/// round-7 adjacency backstop, which asserted two locals inside +/// `assemble_effect_chain` and was therefore not implementable from +/// `crates/engine/tests/`). +/// +/// The observable proxy is the multiset of `PutCounter.counter_type` values +/// reachable from each card's parsed definition. It FAILS if a parser change +/// makes either shipped card bind differently or drop a listed keyword. It +/// deliberately does NOT claim to fire for a NEW card with a non-adjacent +/// directive: such a card emits no clones and no frozen-list test notices. The +/// §7.6 guard is what removes the need for that tripwire — a non-adjacent +/// directive is no longer dropped unless it would transplant a gated referent. +#[test] +fn repeat_process_replicates_every_listed_keyword() { + fn kws(list: &[KeywordKind]) -> Vec { + list.iter().copied().map(CounterType::Keyword).collect() + } + + // Kathril: the flying template + its ten printed clones + the + // "Then put a +1/+1 counter on Kathril" tail. + let kathril = { + let mut v = kws(&[ + KeywordKind::Flying, + KeywordKind::FirstStrike, + KeywordKind::DoubleStrike, + KeywordKind::Deathtouch, + KeywordKind::Hexproof, + KeywordKind::Indestructible, + KeywordKind::Lifelink, + KeywordKind::Menace, + KeywordKind::Reach, + KeywordKind::Trample, + KeywordKind::Vigilance, + ]); + v.push(CounterType::Plus1Plus1); + v + }; + // Super-Adaptoid: the haste template + its ten printed clones. + let super_adaptoid = kws(&[ + KeywordKind::Haste, + KeywordKind::Flying, + KeywordKind::FirstStrike, + KeywordKind::DoubleStrike, + KeywordKind::Deathtouch, + KeywordKind::Indestructible, + KeywordKind::Lifelink, + KeywordKind::Menace, + KeywordKind::Reach, + KeywordKind::Trample, + KeywordKind::Vigilance, + ]); + + let mut reached = 0usize; + for (name, text, types) in REPEAT_PROCESS_CORPUS { + let kinds: Vec = card_definitions(name, text, types) + .iter() + .flat_map(counter_types_of) + .collect(); + let expected: &[CounterType] = match *name { + "Kathril, Aspect Warper" => &kathril, + "Super-Adaptoid" => &super_adaptoid, + _ => &[], + }; + if !expected.is_empty() { + reached += 1; + } + assert_eq!( + kinds, expected, + "{name}: the parsed counter-placement multiset changed" + ); + } + + assert_eq!( + REPEAT_PROCESS_CORPUS.len(), + 12, + "corpus-complete by construction: exactly the cards whose Oracle text carries one of the \ + two anchored directive tags" + ); + assert_eq!( + reached, 2, + "non-vacuity: exactly two of the twelve reach the counter-placement binding" + ); +}