feat(engine): Twinning Staff copy-count replacement#1
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ral parse, zero-copy guard
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements "copy an additional time" replacement effects (such as Twinning Staff) according to CR 707.10, CR 614.1a, and CR 614.5. It introduces a CopyCountStatus enum to prevent runaway copy loops on resumed iterations, integrates the modified copy-count logic into the resolution chain, and adds parser support for these replacement effects alongside comprehensive tests. The reviewer suggested avoiding casting the base count from usize to u32 to prevent potential truncation on 64-bit systems, recommending instead to keep the count as usize and cast the modification values.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
Keep the running copy count as usize and widen the u32 QuantityModification values into it (value as usize, always lossless) instead of narrowing base (usize) to u32 up front, which could truncate on 64-bit targets. Addresses the gemini-code-assist review on PR #1. No behavior change for realistic copy counts; removes a latent narrowing cast. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements Twinning Staff and the broader "copy an additional time" replacement class, on a clean base off current upstream (
twinning-base) so only the fix is in the diff. Opened on the fork for review/approval before any upstream contribution.What
"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."
Approach (building block, not a one-off)
CopySpellreplacement carrying aQuantityModification— the same shape as the token/counter doubling family (Doubling Season, Hardened Scales), so it generalizes to "plus N additional times", not just Twinning Staff.additional/ singular-pluraltime(s)); no verbatim-phrase matching. Coversplus an additional timeandplus N additional times.CopyCountStatusenum (not a bool) guards the per-copy resume so the bonus applies to the copy event once, not per copy.Rules fidelity (CR annotations verified against MagicCompRules)
Fixes
Tests
7 regression tests (parser base + plural/numbered variants; engine: +1 copy, zero-copy guard, opponent's Staff ignored, ability-copies excluded, targeted-spell no-runaway). Full engine suite: 434 passed, 0 failed.
Commits