Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/additional_phase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
284 changes: 284 additions & 0 deletions crates/engine/src/game/effects/copy_spell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +251 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Casting base (which is a usize) to u32 can cause truncation on 64-bit systems if the value exceeds u32::MAX. It is safer and more idiomatic to keep count as a usize and cast the u32 modification values (value) to usize instead.

Suggested change
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
let mut count = base;
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 as usize),
Some(QuantityModification::Minus { value }) => count.saturating_sub(value as usize),
// `Prevent` / unspecified is not a copy-count increase — leave as-is.
Some(QuantityModification::Prevent) | None => count,
};
}
count

}

fn copy_source_entry(state: &GameState, ability: &ResolvedAbility) -> Option<StackEntry> {
let target_id = ability.targets.iter().find_map(|target| match target {
TargetRef::Object(id) => Some(*id),
Expand Down Expand Up @@ -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, &copy, 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, &copy, 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, &copy, 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, &copy, 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, &copy, &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}"
);
}
}
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/double.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/extra_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/effects/player_counter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/skip_next_step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading