From 322182bb3bbef023cd17403cab31cc997d68fb97 Mon Sep 17 00:00:00 2001 From: Danie Date: Mon, 25 May 2026 02:10:41 -0400 Subject: [PATCH 1/4] feat(engine): implement Twinning Staff copy-count replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twinning Staff's activated ability ({7},{T}: copy target instant/sorcery you control) already worked, but its 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." — fell back to Effect::Unimplemented{name:"replacement_structure"}. Implement it as a first-class CopySpell ReplacementDefinition carrying QuantityModification::Plus{value:1}, mirroring the token/counter doubling family (Doubling Season, Hardened Scales). The count is applied at the copy-count chokepoint (the repeat_for loop in effects/mod.rs) via the new helper copy_spell::copy_count_with_replacements, because copies are produced through that loop rather than the ProposedEvent replacement pipeline. Correctness (CR 707.10 + CR 614.1a): - only copies of a *spell* are bumped, not abilities (Gogo); - only the copying player's own Staff applies ("if YOU would copy"); - the bumped count flows into total_iterations/resume stash, so each additional copy runs the normal per-copy retarget step. Tests: parser (line -> CopySpell/Plus{1}) + 3 engine helper tests (count bump, opponent-Staff ignored, ability-copies excluded). Co-Authored-By: Claude Opus 4.7 --- crates/engine/src/game/effects/copy_spell.rs | 160 ++++++++++++++++++ crates/engine/src/game/effects/mod.rs | 13 ++ .../engine/src/parser/oracle_replacement.rs | 72 ++++++++ 3 files changed, 245 insertions(+) diff --git a/crates/engine/src/game/effects/copy_spell.rs b/crates/engine/src/game/effects/copy_spell.rs index 237fd307d3..8d325a5e6b 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -210,6 +210,52 @@ 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 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 +1259,118 @@ 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 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); + } } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 7895c146c3..7af4f34174 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3182,6 +3182,19 @@ 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. + let iterations = if matches!(ability.effect, Effect::CopySpell { .. }) { + 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 { diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 822a410260..1a9786dad2 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. + 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 additional time")), + terminated(nom_primitives::parse_number, tag(" additional 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 +9631,26 @@ 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 }) + ); + } } From d355ec13dc56347de344c0afcfdfa446fea7d79e Mon Sep 17 00:00:00 2001 From: Danie Date: Mon, 25 May 2026 03:23:34 -0400 Subject: [PATCH 2/4] fix(engine): stop Twinning Staff runaway copy loop on targeted spells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying a *targeted* spell with Twinning Staff exploded into dozens of copies (in-game "stuck in a loop"). Each copy pauses on CopyRetarget; the drain driver resumes the next iteration by feeding a single-iteration resume ability (repeat_for cleared) back through resolve_effect. The new CopySpell count hook re-fired on every resumed iteration, re-adding the "+1 additional copy" bonus each time and re-expanding the loop — runaway copies (CR 614.6: a replacement applies to the copy event once, not per copy). Fix: add `copy_count_finalized` to ResolvedAbility. The repeat-loop resume stash sets it, and the CopySpell count hook skips the bonus when it is set, so the Twinning Staff bonus is folded into total_iterations exactly once at the initial resolution. Untargeted copies (no pause) were already correct; this only affects the pause/resume path. Add a regression test that copies a targeted spell with Twinning Staff, drives each retarget pause to completion, and asserts exactly two copies (a runaway trips the loop guard). All ResolvedAbility struct literals get the new field; `..ResolvedAbility::new(..)` spread sites are unchanged. Co-Authored-By: Claude Opus 4.7 --- .../src/game/effects/additional_phase.rs | 1 + crates/engine/src/game/effects/copy_spell.rs | 83 +++++++++++++++++++ crates/engine/src/game/effects/double.rs | 1 + crates/engine/src/game/effects/extra_turn.rs | 1 + .../grant_extra_loyalty_activations.rs | 1 + crates/engine/src/game/effects/mod.rs | 15 +++- .../engine/src/game/effects/player_counter.rs | 2 + .../engine/src/game/effects/skip_next_step.rs | 1 + .../engine/src/game/effects/skip_next_turn.rs | 1 + crates/engine/src/game/effects/vote.rs | 6 ++ crates/engine/src/types/ability.rs | 9 ++ .../the_chain_veil_loyalty_grants.rs | 1 + 12 files changed, 121 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/effects/additional_phase.rs b/crates/engine/src/game/effects/additional_phase.rs index 1e591a71de..a829f9302b 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_finalized: false, 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 8d325a5e6b..8c8d3b0a8b 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -1373,4 +1373,87 @@ mod tests { let copy = copy_top_ability(PlayerId(0)); assert_eq!(copy_count_with_replacements(&state, ©, 1), 1); } + + /// CR 707.10 + CR 614.6: Regression — copying a *targeted* spell with + /// Twinning Staff must make exactly TWO copies, not a runaway. Each copy + /// pauses on `CopyRetarget` and the drain driver resumes the next iteration; + /// without the `copy_count_finalized` 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_finalized 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..96907a4380 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_finalized: false, 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..451b8f8c32 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_finalized: false, 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..405c91a215 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_finalized: false, 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 7af4f34174..29fbb6e1bf 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3189,7 +3189,16 @@ fn resolve_chain_body( // `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. - let iterations = if matches!(ability.effect, Effect::CopySpell { .. }) { + // + // `copy_count_finalized` 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.6 — the replacement applies to the copy + // event once, not to each individual copy). + let iterations = if matches!(ability.effect, Effect::CopySpell { .. }) + && !ability.copy_count_finalized + { copy_spell::copy_count_with_replacements(state, ability, iterations) } else { iterations @@ -3245,6 +3254,10 @@ fn resolve_chain_body( // owns iteration accounting via `next_iteration`. let mut resume_ability = effective.clone(); resume_ability.repeat_for = None; + // CR 614.6: 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. + resume_ability.copy_count_finalized = true; 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..73d23148f9 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_finalized: false, 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_finalized: false, 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..b646135e57 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_finalized: false, 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..15aa2eea95 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_finalized: false, 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..1dd0a605a3 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_finalized: false, 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_finalized: false, 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_finalized: false, 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_finalized: false, 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_finalized: false, 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_finalized: false, forward_result: false, unless_pay: None, distribution: None, diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 344818843b..8bf4a45fd9 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10823,6 +10823,14 @@ 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: Set 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 (which would explode into runaway copies). Only read by + /// the `CopySpell` count hook in `effects::resolve_effect`. + #[serde(default, skip_serializing_if = "is_false")] + pub copy_count_finalized: bool, /// When true, moved/created objects from this effect are forwarded to the sub_ability. #[serde(default)] pub forward_result: bool, @@ -10931,6 +10939,7 @@ impl ResolvedAbility { repeat_for: None, min_x_value: 0, cant_be_copied: false, + copy_count_finalized: false, 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..81fec33f42 100644 --- a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs +++ b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs @@ -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_finalized: false, forward_result: false, unless_pay: None, distribution: None, From 7efcb3ee2cda8824e5c4dd597afb41701ae00937 Mon Sep 17 00:00:00 2001 From: Danie Date: Mon, 25 May 2026 04:37:04 -0400 Subject: [PATCH 3/4] =?UTF-8?q?refactor(engine):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20typed=20CopyCountStatus,=20modular=20plural=20parse?= =?UTF-8?q?,=20zero-copy=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review findings against the project's architectural rules: - R2 (no bool fields): replace `copy_count_finalized: bool` on ResolvedAbility with a typed `CopyCountStatus { Pending, Finalized }` enum, which expresses the design space and reads self-documentingly at the count hook and the repeat-loop resume stash. - R1 / L2 (modular combinators + plural sibling coverage): rebuild the "additional time(s)" parse from composed nom combinators along three independent axes — count (`an` => 1, else a number), the `additional` token, and the singular/plural `time(s)` noun — instead of full-phrase tags. Now "plus an additional time" and "plus N additional times" both parse; added a plural/numbered parser test. - L4 (edge case): guard `copy_count_with_replacements` so the bonus does not apply when the base copy count is zero (CR 614.6 — "if you would copy a spell one or more times" has no event to replace at zero); added a regression test. Co-Authored-By: Claude Opus 4.7 --- .../src/game/effects/additional_phase.rs | 2 +- crates/engine/src/game/effects/copy_spell.rs | 43 ++++++++++++++++++- crates/engine/src/game/effects/double.rs | 2 +- crates/engine/src/game/effects/extra_turn.rs | 2 +- .../grant_extra_loyalty_activations.rs | 2 +- crates/engine/src/game/effects/mod.rs | 7 +-- .../engine/src/game/effects/player_counter.rs | 4 +- .../engine/src/game/effects/skip_next_step.rs | 2 +- .../engine/src/game/effects/skip_next_turn.rs | 2 +- crates/engine/src/game/effects/vote.rs | 12 +++--- .../engine/src/parser/oracle_replacement.rs | 35 ++++++++++++--- crates/engine/src/types/ability.rs | 43 +++++++++++++++---- .../the_chain_veil_loyalty_grants.rs | 6 +-- 13 files changed, 126 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/game/effects/additional_phase.rs b/crates/engine/src/game/effects/additional_phase.rs index a829f9302b..ded48b3e26 100644 --- a/crates/engine/src/game/effects/additional_phase.rs +++ b/crates/engine/src/game/effects/additional_phase.rs @@ -119,7 +119,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 8c8d3b0a8b..fe070ed690 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -231,6 +231,14 @@ pub(crate) fn copy_count_with_replacements( use crate::types::ability::QuantityModification; use crate::types::replacements::ReplacementEvent; + // CR 614.6: "If you would copy a spell *one or more times*" — the + // replacement's precondition. When the effect would make zero copies (e.g. a + // "copy for each X" with X = 0) there is no copy event to replace, 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 { .. }) => {} @@ -1322,6 +1330,37 @@ mod tests { assert_eq!(copy_count_with_replacements(&state, ©, 1), 2); } + /// CR 614.6: "If you would copy a spell *one or more times*" — 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] @@ -1377,7 +1416,7 @@ mod tests { /// CR 707.10 + CR 614.6: Regression — copying a *targeted* spell with /// Twinning Staff must make exactly TWO copies, not a runaway. Each copy /// pauses on `CopyRetarget` and the drain driver resumes the next iteration; - /// without the `copy_count_finalized` guard, every resumed 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] @@ -1438,7 +1477,7 @@ mod tests { guard += 1; assert!( guard < 12, - "runaway copy loop: the copy_count_finalized guard failed to stop re-expansion" + "runaway copy loop: the copy_count_status guard failed to stop re-expansion" ); state.waiting_for = WaitingFor::Priority { player }; state.priority_player = player; diff --git a/crates/engine/src/game/effects/double.rs b/crates/engine/src/game/effects/double.rs index 96907a4380..1433887ff3 100644 --- a/crates/engine/src/game/effects/double.rs +++ b/crates/engine/src/game/effects/double.rs @@ -300,7 +300,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 451b8f8c32..80441bce0d 100644 --- a/crates/engine/src/game/effects/extra_turn.rs +++ b/crates/engine/src/game/effects/extra_turn.rs @@ -80,7 +80,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 405c91a215..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,7 +109,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 29fbb6e1bf..1d384ff67d 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3190,14 +3190,14 @@ fn resolve_chain_body( // `total_iterations` and the resume stash below, so each additional // copy runs the same per-copy retarget step as the base copies. // - // `copy_count_finalized` guards against re-application: each per-copy + // `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.6 — the replacement applies to the copy // event once, not to each individual copy). let iterations = if matches!(ability.effect, Effect::CopySpell { .. }) - && !ability.copy_count_finalized + && ability.copy_count_status.is_pending() { copy_spell::copy_count_with_replacements(state, ability, iterations) } else { @@ -3257,7 +3257,8 @@ fn resolve_chain_body( // CR 614.6: 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. - resume_ability.copy_count_finalized = true; + 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 73d23148f9..83a02642e2 100644 --- a/crates/engine/src/game/effects/player_counter.rs +++ b/crates/engine/src/game/effects/player_counter.rs @@ -214,7 +214,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -356,7 +356,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 b646135e57..611806cc8c 100644 --- a/crates/engine/src/game/effects/skip_next_step.rs +++ b/crates/engine/src/game/effects/skip_next_step.rs @@ -94,7 +94,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 15aa2eea95..c15cafe43c 100644 --- a/crates/engine/src/game/effects/skip_next_turn.rs +++ b/crates/engine/src/game/effects/skip_next_turn.rs @@ -101,7 +101,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 1dd0a605a3..f30c5a9b9d 100644 --- a/crates/engine/src/game/effects/vote.rs +++ b/crates/engine/src/game/effects/vote.rs @@ -251,7 +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_finalized: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: per_choice_effect[idx].forward_result, unless_pay: None, distribution: None, @@ -315,7 +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_finalized: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: def.forward_result, unless_pay: None, distribution: None, @@ -461,7 +461,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -551,7 +551,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -842,7 +842,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: false, + copy_count_status: crate::types::ability::CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, @@ -989,7 +989,7 @@ mod tests { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 1a9786dad2..e72e13d21e 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -3645,14 +3645,16 @@ fn parse_copy_count_replacement(lower: &str, original_text: &str) -> Option 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 additional time")), - terminated(nom_primitives::parse_number, tag(" additional time")), - )) - .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)?; @@ -9653,4 +9655,27 @@ mod snapshot_tests { 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 8bf4a45fd9..03499a1c8e 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10758,6 +10758,31 @@ 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.6), 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,14 +10848,14 @@ 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: Set 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 (which would explode into runaway copies). Only read by - /// the `CopySpell` count hook in `effects::resolve_effect`. - #[serde(default, skip_serializing_if = "is_false")] - pub copy_count_finalized: bool, + /// CR 707.10 + CR 614.1a: `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 (which 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, @@ -10939,7 +10964,7 @@ impl ResolvedAbility { repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: 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 81fec33f42..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,7 +135,7 @@ fn make_grant_ability(controller: PlayerId, source: ObjectId) -> ResolvedAbility repeat_for: None, min_x_value: 0, cant_be_copied: false, - copy_count_finalized: false, + copy_count_status: CopyCountStatus::Pending, forward_result: false, unless_pay: None, distribution: None, From 7f98129f14b9179982b1f73ec4c5c9f91a7528a3 Mon Sep 17 00:00:00 2001 From: Danie Date: Mon, 25 May 2026 23:38:28 -0400 Subject: [PATCH 4/4] fix(engine): correct Twinning Staff CR annotations to verified rules R6 self-review of the Twinning Staff copy-count work: the "applies once, not per copy" anti-runaway guard and the "one or more times" precondition were both annotated CR 614.6, whose body ("a replaced event never happens") describes neither claim. Verified against docs/MagicCompRules.txt and corrected: - "applies once / not invoked repeatedly per copy" -> CR 614.5 (a replacement effect gets only one opportunity to affect an event) - "one or more times" precondition / zero-copies guard -> CR 614.1 (a replacement effect watches for an event that would happen) Comment/doc-comment only; no logic change. Co-Authored-By: Claude Opus 4.7 --- crates/engine/src/game/effects/copy_spell.rs | 20 +++++++++++--------- crates/engine/src/game/effects/mod.rs | 11 +++++++---- crates/engine/src/types/ability.rs | 20 ++++++++++++-------- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/game/effects/copy_spell.rs b/crates/engine/src/game/effects/copy_spell.rs index fe070ed690..533ceb44eb 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -231,10 +231,10 @@ pub(crate) fn copy_count_with_replacements( use crate::types::ability::QuantityModification; use crate::types::replacements::ReplacementEvent; - // CR 614.6: "If you would copy a spell *one or more times*" — the - // replacement's precondition. When the effect would make zero copies (e.g. a - // "copy for each X" with X = 0) there is no copy event to replace, so the - // bonus must not apply. + // 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; } @@ -1330,9 +1330,10 @@ mod tests { assert_eq!(copy_count_with_replacements(&state, ©, 1), 2); } - /// CR 614.6: "If you would copy a spell *one or more times*" — 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. + /// 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); @@ -1413,8 +1414,9 @@ mod tests { assert_eq!(copy_count_with_replacements(&state, ©, 1), 1); } - /// CR 707.10 + CR 614.6: Regression — copying a *targeted* spell with - /// Twinning Staff must make exactly TWO copies, not a runaway. Each copy + /// 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 diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 1d384ff67d..093ee89d82 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3194,8 +3194,9 @@ fn resolve_chain_body( // 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.6 — the replacement applies to the copy - // event once, not to each individual copy). + // 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() { @@ -3254,9 +3255,11 @@ fn resolve_chain_body( // owns iteration accounting via `next_iteration`. let mut resume_ability = effective.clone(); resume_ability.repeat_for = None; - // CR 614.6: the copy-count replacement bonus is already + // 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. + // 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 = diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 03499a1c8e..67c8e92b7d 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10764,8 +10764,10 @@ pub enum KeywordAction { /// /// 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.6), not per copy, so a -/// resumed iteration is marked `Finalized` and the count hook skips it. +/// 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. @@ -10848,12 +10850,14 @@ 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: `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 (which would explode into runaway copies). Only - /// read by the `CopySpell` count hook in `effects::resolve_effect`. + /// 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.