diff --git a/crates/engine/src/game/effects/additional_phase.rs b/crates/engine/src/game/effects/additional_phase.rs index 1e591a71de..ded48b3e26 100644 --- a/crates/engine/src/game/effects/additional_phase.rs +++ b/crates/engine/src/game/effects/additional_phase.rs @@ -119,6 +119,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/game/effects/copy_spell.rs b/crates/engine/src/game/effects/copy_spell.rs index 237fd307d3..533ceb44eb 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -210,6 +210,60 @@ fn copy_controller(ability: &ResolvedAbility) -> PlayerId { .unwrap_or(ability.controller) } +/// CR 707.10 + CR 614.1a: Apply active "copy an additional time" replacement +/// effects (Twinning Staff) to the number of copies a `CopySpell` effect would +/// create. `base` is the count the effect would otherwise produce (its +/// `repeat_for` value, or 1); the return value is the modified count. +/// +/// Copies are produced by the generic `repeat_for` loop, not the +/// `ProposedEvent` replacement pipeline, so the count modification is applied +/// here at the copy-count site. Only copies of a *spell* are affected — copying +/// an activated/triggered ability (Gogo) is not "copying a spell" (CR 707.10). +/// Each `CopySpell` replacement controlled by the copy's controller folds its +/// `QuantityModification` into the count; purely additive `Plus` modifications +/// (the only shape in the current card pool) are order-independent, so no +/// CR 616.1 ordering choice is required. +pub(crate) fn copy_count_with_replacements( + state: &GameState, + ability: &ResolvedAbility, + base: usize, +) -> usize { + use crate::types::ability::QuantityModification; + use crate::types::replacements::ReplacementEvent; + + // CR 614.1: "If you would copy a spell *one or more times*" — a replacement + // effect watches for a particular event that *would happen*. When the effect + // would make zero copies (e.g. a "copy for each X" with X = 0) there is no + // copy event to watch for, so the bonus must not apply. + if base == 0 { + return 0; + } + + // CR 707.10: Twinning Staff only modifies copying a *spell*, not an ability. + match copy_source_entry(state, ability) { + Some(entry) if matches!(entry.kind, StackEntryKind::Spell { .. }) => {} + _ => return base, + } + + // CR 707.10 / CR 614.1a: "if you would copy" — only the copy controller's + // copy-additional replacements apply. + let controller = copy_controller(ability); + let mut count = base as u32; + for (_idx, obj, def) in crate::game::functioning_abilities::active_replacements(state) { + if def.event != ReplacementEvent::CopySpell || obj.controller != controller { + continue; + } + count = match def.quantity_modification { + Some(QuantityModification::Double) => count.saturating_mul(2), + Some(QuantityModification::Plus { value }) => count.saturating_add(value), + Some(QuantityModification::Minus { value }) => count.saturating_sub(value), + // `Prevent` / unspecified is not a copy-count increase — leave as-is. + Some(QuantityModification::Prevent) | None => count, + }; + } + count as usize +} + fn copy_source_entry(state: &GameState, ability: &ResolvedAbility) -> Option { let target_id = ability.targets.iter().find_map(|target| match target { TargetRef::Object(id) => Some(*id), @@ -1213,4 +1267,234 @@ mod tests { >= 2 ); } + + /// Put a Twinning Staff–style permanent (a `CopySpell` replacement with + /// `Plus { value: 1 }`) onto the battlefield under `controller`. + fn push_twinning_staff(state: &mut GameState, obj_id: ObjectId, controller: PlayerId) { + use crate::types::ability::{QuantityModification, ReplacementDefinition}; + use crate::types::replacements::ReplacementEvent; + + let mut obj = GameObject::new( + obj_id, + CardId(900), + controller, + "Twinning Staff".to_string(), + Zone::Battlefield, + ); + obj.controller = controller; + obj.replacement_definitions = vec![ReplacementDefinition::new(ReplacementEvent::CopySpell) + .quantity_modification(QuantityModification::Plus { value: 1 })] + .into(); + state.objects.insert(obj_id, obj); + } + + /// Build a `CopySpell` ability (no targets → copies top of stack) for `controller`. + fn copy_top_ability(controller: PlayerId) -> ResolvedAbility { + ResolvedAbility::new( + Effect::CopySpell { + target: TargetFilter::Any, + retarget: CopyRetargetPermission::MayChooseNewTargets, + }, + vec![], + ObjectId(800), + controller, + ) + } + + /// CR 707.10 + CR 614.1a: Twinning Staff turns a single spell copy into two. + #[test] + fn copy_count_with_replacements_adds_one_for_twinning_staff() { + let mut state = GameState::new_two_player(42); + push_twinning_staff(&mut state, ObjectId(50), PlayerId(0)); + + let spell = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(10), + PlayerId(0), + ); + push_spell( + &mut state, + ObjectId(10), + CardId(1), + PlayerId(0), + "Divination", + spell, + CastingVariant::Normal, + ); + + let copy = copy_top_ability(PlayerId(0)); + assert_eq!(copy_count_with_replacements(&state, ©, 1), 2); + } + + /// CR 614.1: "If you would copy a spell *one or more times*" — a replacement + /// effect watches for an event that would happen; when the base copy count is + /// zero (e.g. a "copy for each X" with X = 0) there is no copy event, so + /// Twinning Staff must NOT manufacture one. + #[test] + fn copy_count_with_replacements_does_not_apply_to_zero_copies() { + let mut state = GameState::new_two_player(42); + push_twinning_staff(&mut state, ObjectId(50), PlayerId(0)); + + let spell = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(10), + PlayerId(0), + ); + push_spell( + &mut state, + ObjectId(10), + CardId(1), + PlayerId(0), + "Divination", + spell, + CastingVariant::Normal, + ); + + let copy = copy_top_ability(PlayerId(0)); + assert_eq!(copy_count_with_replacements(&state, ©, 0), 0); + } + + /// CR 707.10: "If YOU would copy" — only the copying player's Twinning Staff + /// applies. An opponent's Staff must not modify the count. + #[test] + fn copy_count_with_replacements_ignores_opponents_staff() { + let mut state = GameState::new_two_player(42); + push_twinning_staff(&mut state, ObjectId(50), PlayerId(1)); + + let spell = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(10), + PlayerId(0), + ); + push_spell( + &mut state, + ObjectId(10), + CardId(1), + PlayerId(0), + "Divination", + spell, + CastingVariant::Normal, + ); + + let copy = copy_top_ability(PlayerId(0)); + assert_eq!(copy_count_with_replacements(&state, ©, 1), 1); + } + + /// CR 707.10: Copying an *ability* (not a spell) is unaffected by Twinning + /// Staff. With only a triggered ability on the stack, the count is unchanged. + #[test] + fn copy_count_with_replacements_excludes_ability_copies() { + let mut state = GameState::new_two_player(42); + push_twinning_staff(&mut state, ObjectId(50), PlayerId(0)); + + let trigger = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(11), + PlayerId(0), + ); + push_trigger(&mut state, ObjectId(11), CardId(2), PlayerId(0), trigger); + + let copy = copy_top_ability(PlayerId(0)); + assert_eq!(copy_count_with_replacements(&state, ©, 1), 1); + } + + /// CR 707.10 + CR 614.5: Regression — copying a *targeted* spell with + /// Twinning Staff must make exactly TWO copies, not a runaway. A replacement + /// effect gets only one opportunity to affect an event (CR 614.5). Each copy + /// pauses on `CopyRetarget` and the drain driver resumes the next iteration; + /// without the `copy_count_status` guard, every resumed iteration + /// re-applied the +1 bonus and the loop exploded into dozens of copies (the + /// in-game "stuck in a loop" report). + #[test] + fn twinning_staff_targeted_copy_does_not_runaway() { + use crate::types::card_type::CoreType; + + let mut state = GameState::new_two_player(42); + push_twinning_staff(&mut state, ObjectId(50), PlayerId(0)); + + // A creature for the copied spell to target. + let mut bear = GameObject::new( + ObjectId(60), + CardId(5), + PlayerId(1), + "Bear".to_string(), + Zone::Battlefield, + ); + bear.card_types.core_types.push(CoreType::Creature); + state.objects.insert(ObjectId(60), bear); + + // A targeted instant on the stack (Lightning Bolt-style), controlled by P0. + let spell = ResolvedAbility::new( + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Any, + damage_source: None, + }, + vec![TargetRef::Object(ObjectId(60))], + ObjectId(10), + PlayerId(0), + ); + push_spell( + &mut state, + ObjectId(10), + CardId(1), + PlayerId(0), + "Lightning Bolt", + spell, + CastingVariant::Normal, + ); + + // Resolve a "copy target spell, you may choose new targets" effect. + let copy = ResolvedAbility::new( + Effect::CopySpell { + target: TargetFilter::Any, + retarget: CopyRetargetPermission::MayChooseNewTargets, + }, + vec![], + ObjectId(70), + PlayerId(0), + ); + let mut events = Vec::new(); + let _ = crate::game::effects::resolve_ability_chain(&mut state, ©, &mut events, 0); + + // Drive each per-copy retarget pause to completion (keep current targets). + let mut guard = 0; + while let WaitingFor::CopyRetarget { player, .. } = state.waiting_for.clone() { + guard += 1; + assert!( + guard < 12, + "runaway copy loop: the copy_count_status guard failed to stop re-expansion" + ); + state.waiting_for = WaitingFor::Priority { player }; + state.priority_player = player; + crate::game::effects::drain_pending_continuation(&mut state, &mut events); + } + + // Exactly two spell copies (base 1 + Twinning Staff's additional 1). + let copies = state + .objects + .values() + .filter(|o| o.is_token && o.zone == Zone::Stack) + .count(); + assert_eq!( + copies, 2, + "Twinning Staff must make exactly one extra copy (2 total), got {copies}" + ); + } } diff --git a/crates/engine/src/game/effects/double.rs b/crates/engine/src/game/effects/double.rs index 2d20512e7a..1433887ff3 100644 --- a/crates/engine/src/game/effects/double.rs +++ b/crates/engine/src/game/effects/double.rs @@ -300,6 +300,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/game/effects/extra_turn.rs b/crates/engine/src/game/effects/extra_turn.rs index 3797ab6322..80441bce0d 100644 --- a/crates/engine/src/game/effects/extra_turn.rs +++ b/crates/engine/src/game/effects/extra_turn.rs @@ -80,6 +80,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs index 2dc7cac626..13c1a19a50 100644 --- a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs +++ b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs @@ -109,6 +109,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 7895c146c3..093ee89d82 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3182,6 +3182,29 @@ fn resolve_chain_body( 1 }; + // CR 707.10 + CR 614.1a: "copy an additional time" replacement + // effects (Twinning Staff) increase how many copies a copy-a-spell + // effect produces. Applied once here at the copy-count site because + // copies are created through this `repeat_for` loop, not the + // `ProposedEvent` replacement pipeline. The adjusted count flows into + // `total_iterations` and the resume stash below, so each additional + // copy runs the same per-copy retarget step as the base copies. + // + // `copy_count_status` guards against re-application: each per-copy + // retarget pause re-stashes a single-iteration resume ability that the + // drain driver feeds back through this code. Without the guard, every + // resumed iteration would re-add the bonus and the loop would explode + // into runaway copies (CR 614.5 — a replacement effect doesn't invoke + // itself repeatedly; it gets only one opportunity to affect an event, + // so the bonus applies to the copy event once, not per individual copy). + let iterations = if matches!(ability.effect, Effect::CopySpell { .. }) + && ability.copy_count_status.is_pending() + { + copy_spell::copy_count_with_replacements(state, ability, iterations) + } else { + iterations + }; + let initial_waiting_for = state.waiting_for.clone(); let mut iteration = 0usize; while iteration < iterations { @@ -3232,6 +3255,13 @@ fn resolve_chain_body( // owns iteration accounting via `next_iteration`. let mut resume_ability = effective.clone(); resume_ability.repeat_for = None; + // CR 614.5: the copy-count replacement bonus is already + // folded into `total_iterations`; mark the resume so the + // CopySpell count hook does not re-add it per resumed copy + // (a replacement effect gets only one opportunity to affect + // an event, so it must not re-fire on each resumed copy). + resume_ability.copy_count_status = + crate::types::ability::CopyCountStatus::Finalized; state.pending_repeat_iteration = Some(crate::types::game_state::PendingRepeatIteration { ability: Box::new(resume_ability), diff --git a/crates/engine/src/game/effects/player_counter.rs b/crates/engine/src/game/effects/player_counter.rs index 756868cd07..83a02642e2 100644 --- a/crates/engine/src/game/effects/player_counter.rs +++ b/crates/engine/src/game/effects/player_counter.rs @@ -214,6 +214,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -355,6 +356,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/game/effects/skip_next_step.rs b/crates/engine/src/game/effects/skip_next_step.rs index fad9e6d4e8..611806cc8c 100644 --- a/crates/engine/src/game/effects/skip_next_step.rs +++ b/crates/engine/src/game/effects/skip_next_step.rs @@ -94,6 +94,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/game/effects/skip_next_turn.rs b/crates/engine/src/game/effects/skip_next_turn.rs index 90d6c6332f..c15cafe43c 100644 --- a/crates/engine/src/game/effects/skip_next_turn.rs +++ b/crates/engine/src/game/effects/skip_next_turn.rs @@ -101,6 +101,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/game/effects/vote.rs b/crates/engine/src/game/effects/vote.rs index dd257fa8c6..f30c5a9b9d 100644 --- a/crates/engine/src/game/effects/vote.rs +++ b/crates/engine/src/game/effects/vote.rs @@ -251,6 +251,7 @@ pub fn resolve_tally( repeat_for, min_x_value: per_choice_effect[idx].min_x_value, cant_be_copied: per_choice_effect[idx].cant_be_copied, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: per_choice_effect[idx].forward_result, unless_pay: None, distribution: None, @@ -314,6 +315,7 @@ fn resolved_from_def( repeat_for: None, min_x_value: def.min_x_value, cant_be_copied: def.cant_be_copied, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: def.forward_result, unless_pay: None, distribution: None, @@ -459,6 +461,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -548,6 +551,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -838,6 +842,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -984,6 +989,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 822a410260..e72e13d21e 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -397,6 +397,19 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option bool { false } +/// CR 707.10 + CR 614.1a: Parse a "copy an additional time" replacement — +/// "If you would copy a spell one or more times, instead copy it that many +/// times plus an additional time. You may choose new targets for the additional +/// copy." (Twinning Staff). +/// +/// Modeled as a `CopySpell` replacement carrying a `QuantityModification`, +/// mirroring the token/counter doubling family (Doubling Season, Hardened +/// Scales). Generalizes to "plus N additional times" via `parse_number`. The +/// count change is consumed by `copy_spell::copy_count_with_replacements` at the +/// copy-count site — copies are produced by the `repeat_for` loop, not the +/// `ProposedEvent` pipeline, so this replacement is queried directly rather than +/// proposed. The additional copies always permit new targets (standard wording +/// for this class), satisfied by each copy's existing retarget step. +fn parse_copy_count_replacement(lower: &str, original_text: &str) -> Option { + use crate::types::ability::QuantityModification; + + // Require the "plus [N] additional time(s)" tail so this only matches the + // count-increasing class, not an unrelated one-shot "copy a spell" effect. + // Composed from modular combinators along three independent axes — count + // (`an` => 1, else a number), the fixed `additional` token, and the + // singular/plural `time(s)` noun — rather than enumerating full-phrase tags, + // so "plus an additional time" and "plus N additional times" both parse. + let additional = nom_on_lower(lower, lower, |i| { + let (i, _) = take_until::<_, _, OracleError<'_>>("plus ").parse(i)?; + let (i, _) = tag("plus ").parse(i)?; + let (i, n) = alt((value(1u32, tag("an")), nom_primitives::parse_number)).parse(i)?; + let (i, _) = tag(" additional ").parse(i)?; + let (i, _) = alt((tag("times"), tag("time"))).parse(i)?; + Ok((i, n)) + }) + .map(|(n, _)| n)?; + + Some( + ReplacementDefinition::new(ReplacementEvent::CopySpell) + .quantity_modification(QuantityModification::Plus { value: additional }) + .description(original_text.to_string()), + ) +} + /// CR 614.1a: Parse token creation replacement effects. /// Handles "twice that many tokens" (Primal Vigor, Doubling Season, Parallel Lives) /// and "those tokens plus [spec]" (Chatterfang — "that many 1/1 green Squirrel @@ -9581,4 +9633,49 @@ mod snapshot_tests { assert!(!no_modal, "mandatory effect text must not be misclassified"); assert_eq!(unchanged, "draw two cards"); } + + /// CR 707.10 + CR 614.1a: Twinning Staff's "If you would copy a spell one or + /// more times, instead copy it that many times plus an additional time" + /// parses to a `CopySpell` replacement carrying `Plus { value: 1 }`. + #[test] + fn copy_count_replacement_parses_twinning_staff() { + use crate::types::ability::QuantityModification; + use crate::types::replacements::ReplacementEvent; + + let def = super::parse_replacement_line( + "If you would copy a spell one or more times, instead copy it that many times \ + plus an additional time. You may choose new targets for the additional copy.", + "Twinning Staff", + ) + .expect("Twinning Staff replacement must parse"); + + assert_eq!(def.event, ReplacementEvent::CopySpell); + assert_eq!( + def.quantity_modification, + Some(QuantityModification::Plus { value: 1 }) + ); + } + + /// The "additional time(s)" tail is composed from modular combinators, so a + /// numbered, pluralized variant ("plus 2 additional times") parses to the + /// corresponding `Plus { value }` — sibling coverage beyond the single + /// Twinning Staff wording. + #[test] + fn copy_count_replacement_parses_plural_numbered_variant() { + use crate::types::ability::QuantityModification; + use crate::types::replacements::ReplacementEvent; + + let def = super::parse_replacement_line( + "If you would copy a spell one or more times, instead copy it that many times \ + plus 2 additional times.", + "Hypothetical Double Staff", + ) + .expect("plural numbered copy-count replacement must parse"); + + assert_eq!(def.event, ReplacementEvent::CopySpell); + assert_eq!( + def.quantity_modification, + Some(QuantityModification::Plus { value: 2 }) + ); + } } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 344818843b..67c8e92b7d 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10758,6 +10758,33 @@ pub enum KeywordAction { // Resolved ability -- simplified, zero HashMap // --------------------------------------------------------------------------- +/// CR 707.10 + CR 614.1a: Whether copy-count replacement effects (Twinning +/// Staff's "copy an additional time") have already been folded into a +/// `CopySpell` resolution's iteration count. +/// +/// A `CopySpell` of a targeted spell pauses on `CopyRetarget` per copy; the +/// drain driver then resumes the next iteration with a single-iteration ability. +/// The bonus must apply to the copy *event* once (CR 614.5 — a replacement +/// effect doesn't invoke itself repeatedly; it gets only one opportunity to +/// affect an event), not per copy, so a resumed iteration is marked `Finalized` +/// and the count hook skips it. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CopyCountStatus { + /// Initial resolution — copy-count replacements not yet applied. + #[default] + Pending, + /// Resumed iteration — the bonus is already folded into the iteration count. + Finalized, +} + +impl CopyCountStatus { + /// True for the initial resolution (the only state in which copy-count + /// replacements should be applied). + pub fn is_pending(&self) -> bool { + matches!(self, CopyCountStatus::Pending) + } +} + /// Runtime ability data passed to effect handlers at resolution time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedAbility { @@ -10823,6 +10850,16 @@ pub struct ResolvedAbility { /// Stack-copy restriction from "This ability can't be copied." #[serde(default, skip_serializing_if = "is_false")] pub cant_be_copied: bool, + /// CR 707.10 + CR 614.1a + CR 614.5: `Finalized` on a `repeat_for` iteration + /// that the drain driver resumes after a per-copy pause, so the "copy an + /// additional time" replacement bonus (Twinning Staff) is folded into the + /// iteration count exactly once — at the initial resolution — and never + /// re-applied on each resumed iteration (CR 614.5: a replacement effect gets + /// only one opportunity to affect an event; re-applying would explode into + /// runaway copies). Only read by the `CopySpell` count hook in + /// `effects::resolve_effect`. + #[serde(default, skip_serializing_if = "CopyCountStatus::is_pending")] + pub copy_count_status: CopyCountStatus, /// When true, moved/created objects from this effect are forwarded to the sub_ability. #[serde(default)] pub forward_result: bool, @@ -10931,6 +10968,7 @@ impl ResolvedAbility { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs index 79e4699428..64b51233d0 100644 --- a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs +++ b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs @@ -26,8 +26,8 @@ use engine::game::engine::apply; use engine::game::planeswalker; use engine::game::zones::create_object; use engine::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, Effect, QuantityExpr, ResolvedAbility, - SubAbilityLink, TargetFilter, TargetRef, TargetSelectionMode, + AbilityCost, AbilityDefinition, AbilityKind, CopyCountStatus, Effect, QuantityExpr, + ResolvedAbility, SubAbilityLink, TargetFilter, TargetRef, TargetSelectionMode, }; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; @@ -135,6 +135,7 @@ fn make_grant_ability(controller: PlayerId, source: ObjectId) -> ResolvedAbility repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_status: CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None,