Skip to content

feat(engine): Twinning Staff copy-count replacement#1

Closed
danielbrock58 wants to merge 4 commits into
twinning-basefrom
twinning-staff-copy-count
Closed

feat(engine): Twinning Staff copy-count replacement#1
danielbrock58 wants to merge 4 commits into
twinning-basefrom
twinning-staff-copy-count

Conversation

@danielbrock58

Copy link
Copy Markdown
Owner

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)

  • Modeled as a CopySpell replacement carrying a QuantityModification — 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.
  • Parser composes nom combinators along independent axes (count / additional / singular-plural time(s)); no verbatim-phrase matching. Covers plus an additional time and plus N additional times.
  • New CopyCountStatus enum (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)

  • CR 707.10 — copying a spell vs. an ability (Staff affects spell copies only)
  • CR 614.1 / 614.1a — replacement watches for an event that would happen; "instead" replacement
  • CR 614.5 — a replacement effect doesn't invoke itself repeatedly (the anti-runaway guard)
  • CR 616.1 — additive modifications are order-independent (no ordering choice needed)

Fixes

  • Runaway copy loop when copying a targeted spell with Twinning Staff (each per-copy retarget pause re-applied the bonus → dozens of copies). Now exactly base + 1.

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

  • implement copy-count replacement
  • stop runaway copy loop on targeted spells
  • typed CopyCountStatus, modular plural parse, zero-copy guard
  • correct CR annotations to verified rules

danielbrock58 and others added 4 commits May 26, 2026 02:04
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +251 to +264
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

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

danielbrock58 added a commit that referenced this pull request May 26, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant