Skip to content

refactor(engine): extract the CR 707.10 copy-onto-stack authority - #6647

Merged
matthewevans merged 1 commit into
mainfrom
cr733/copy-push-authority
Jul 26, 2026
Merged

refactor(engine): extract the CR 707.10 copy-onto-stack authority#6647
matthewevans merged 1 commit into
mainfrom
cr733/copy-push-authority

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 26, 2026

Copy link
Copy Markdown
Member

Four copy effects hand-rolled their own state.stack.push_back plus a
manual GameEvent::StackPushed, bypassing any shared push path:
copy_spell, cast_copy_of_card, paradigm, and epic.

They are not deviations from stack::push_to_stack that should be
funnelled into it — putting an object onto the stack (CR 405.1 /
CR 601.2a) and copying one onto it (CR 707.10) are distinct sub-families
that disagree on both source-referential stamps:

  • CR 701.27f generation — a copy captures the source's generation at
    copy-creation time and must overwrite what the copied ability inherited
    from the original's earlier push. push_to_stack guards its stamp with
    is_none() so a delayed triggered ability's creation-time generation
    survives firing; applying that guard to a copy leaves it comparing
    against the original's generation and silently no-opping.
  • Force-block binding — a copied ability already carries the
    force_block_attacker its original was bound to, sourced from the
    trigger's captured trigger_source provenance. Re-running
    bind_force_block_source_recursive would overwrite that exact
    choice-time referent with a live state.objects rescan, contradicting
    CR 707.10b ("a copy of an ability has the same source as the original").

So this adds stack::push_copy_to_stack as the copy-family sibling
rather than merging the two. It stamps the CR 701.27f generation
unconditionally (lifted verbatim from copy_spell, including its
activated/triggered gate — CR 701.27f covers only "an activated or
triggered ability of a permanent", so the stamp is correctly inert for
the three spell-copy callers), deliberately does not re-bind force-block,
and emits StackPushed exactly once. Each of the four hand-rolled
emissions was positionally equivalent, so event order is unchanged.

Behavior is unchanged; the new test pins the CR 701.27f semantics that
were previously implicit in one caller and are now the authority's
contract. It drives a real double activation plus a real Lithoform Engine
copy, and fails (generation 1, not 2) if the stamp is re-guarded with
is_none().

Production state.stack.push_back sites are now exactly two, one per
family authority, both in game/stack.rs.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of copied spells and abilities on the stack.
    • Copied abilities now retain the correct transformation state when intervening transformations occur.
    • Fixed related stack events and copy behavior for reliable resolution.
  • Tests

    • Added integration coverage for copied transformed abilities and their resolution order.

Four copy effects hand-rolled their own `state.stack.push_back` plus a
manual `GameEvent::StackPushed`, bypassing any shared push path:
`copy_spell`, `cast_copy_of_card`, `paradigm`, and `epic`.

They are not deviations from `stack::push_to_stack` that should be
funnelled into it — putting an object onto the stack (CR 405.1 /
CR 601.2a) and copying one onto it (CR 707.10) are distinct sub-families
that disagree on both source-referential stamps:

* CR 701.27f generation — a copy captures the source's generation at
  copy-creation time and must overwrite what the copied ability inherited
  from the original's earlier push. `push_to_stack` guards its stamp with
  `is_none()` so a delayed triggered ability's creation-time generation
  survives firing; applying that guard to a copy leaves it comparing
  against the original's generation and silently no-opping.
* Force-block binding — a copied ability already carries the
  `force_block_attacker` its original was bound to, sourced from the
  trigger's captured `trigger_source` provenance. Re-running
  `bind_force_block_source_recursive` would overwrite that exact
  choice-time referent with a live `state.objects` rescan, contradicting
  CR 707.10b ("a copy of an ability has the same source as the original").

So this adds `stack::push_copy_to_stack` as the copy-family sibling
rather than merging the two. It stamps the CR 701.27f generation
unconditionally (lifted verbatim from `copy_spell`, including its
activated/triggered gate — CR 701.27f covers only "an activated or
triggered ability of a permanent", so the stamp is correctly inert for
the three spell-copy callers), deliberately does not re-bind force-block,
and emits `StackPushed` exactly once. Each of the four hand-rolled
emissions was positionally equivalent, so event order is unchanged.

Behavior is unchanged; the new test pins the CR 701.27f semantics that
were previously implicit in one caller and are now the authority's
contract. It drives a real double activation plus a real Lithoform Engine
copy, and fails (generation 1, not 2) if the stamp is re-guarded with
`is_none()`.

Production `state.stack.push_back` sites are now exactly two, one per
family authority, both in `game/stack.rs`.
@matthewevans
matthewevans enabled auto-merge July 26, 2026 04:34
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 09ef52ac-fcdc-41bc-817f-cf5c27536b79

📥 Commits

Reviewing files that changed from the base of the PR and between 5a2341d and e3061b2.

📒 Files selected for processing (7)
  • crates/engine/src/game/effects/cast_copy_of_card.rs
  • crates/engine/src/game/effects/copy_spell.rs
  • crates/engine/src/game/effects/epic.rs
  • crates/engine/src/game/effects/paradigm.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/tests/integration/copied_ability_transform_generation.rs
  • crates/engine/tests/integration/main.rs

📝 Walkthrough

Walkthrough

Copied spells and abilities now use push_copy_to_stack, which centralizes transformation-generation stamping, stack insertion, and StackPushed event emission. A new integration test validates copy-time generation behavior across intervening transformations.

Changes

Copied ability stack flow

Layer / File(s) Summary
Copy stack helper
crates/engine/src/game/stack.rs
Adds push_copy_to_stack to stamp copied activated or triggered abilities, emit one StackPushed event, and enqueue the entry without force-block rebinding.
Copy effect wiring
crates/engine/src/game/effects/cast_copy_of_card.rs, crates/engine/src/game/effects/copy_spell.rs, crates/engine/src/game/effects/epic.rs, crates/engine/src/game/effects/paradigm.rs
Routes copy-created stack entries through the shared helper and removes local generation stamping and event emission.
Transformation-generation integration test
crates/engine/tests/integration/copied_ability_transform_generation.rs, crates/engine/tests/integration/main.rs
Adds and registers a scenario verifying copy-time transformation-generation capture across two transformations.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • phase-rs/phase#6352: Updates the same copy-on-stack effect paths with different copied-entry metadata handling.

Suggested labels: bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the refactor to a new CR 707.10 copy-onto-stack helper and the main changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cr733/copy-push-authority

Comment @coderabbitai help to get the list of available commands.

@matthewevans
matthewevans added this pull request to the merge queue Jul 26, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

Merged via the queue into main with commit 855c8c7 Jul 26, 2026
15 checks passed
@matthewevans
matthewevans deleted the cr733/copy-push-authority branch July 26, 2026 05:11
matthewevans added a commit to mike-theDude/phase that referenced this pull request Jul 26, 2026
…uthority (phase-rs#6664)

The "push first, choose second" invariant puts a triggered ability on the stack
before its choices are gathered (see `GameState::pending_trigger_entry`), so the
entry is live while a `WaitingFor` fills its slots. When those choices cannot be
completed, CR 603.3d says the ability is simply removed from the stack.

That removal was written raw at SIX sites as a byte-identical seven-line block
(verified by whole-block comparison, not a sampled window): the pop, plus the two
per-entry side tables that settle with it.

    if let Some(entry_id) = state.pending_trigger_entry.take() {
        if state.stack.back().map(|e| e.id) == Some(entry_id) {
            state.stack.pop_back();
            state.stack_paid_facts.remove(&entry_id);
            state.stack_trigger_event_batches.remove(&entry_id);
        }
    }

One of the six was already inside `drop_mid_construction_pending_trigger`, so
this is one existing authority plus five sites bypassing it — not six orphans.
The five are in `begin_pending_trigger_target_selection` (x4) and
`resolve_random_modal_trigger`.

Routing the five INTO `drop_mid_construction_pending_trigger` would have been
wrong: that function additionally clears `pending_trigger`, which the five
deliberately do not, so it would have been a silent behaviour change at five call
sites. Instead the shared seven lines become
`stack::pop_uncommitted_pending_trigger_entry`, the existing authority calls it
and then clears `pending_trigger`, and the five call it directly. One authority
for the mutation, no semantic change anywhere.

The two side tables are cleared inside the authority rather than by callers
because they are keyed on the entry and settle WITH the pop — a removal that
dropped the entry but left `stack_paid_facts` or `stack_trigger_event_batches`
behind would strand rows against an id no longer on the stack.

Deliberately NOT folded in: `triggers.rs`'s recovery path clears both side tables
with no pop, because its entry has already vanished from the stack. Same two
lines, different operation.

Prerequisite for journaling the CR733 stack-pop family, which requires exactly
one authority per family — the same shape phase-rs#6647 used for the CR 707.10 copy push.
No journaling here; this is the extraction only.

CR 603.3d grep-verified against docs/MagicCompRules.txt: "If a choice is required
when the triggered ability goes on the stack but no legal choices can be made for
it, or if a rule or a continuous effect otherwise makes the ability illegal, the
ability is simply removed from the stack."

Behaviour-preserving: engine integration suite 4057 passed, 0 failed.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
@coderabbitai coderabbitai Bot mentioned this pull request Aug 4, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Aug 15, 2026
4 tasks
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