Skip to content

refactor(engine): extract the CR 603.3d uncommitted-trigger removal authority - #6664

Merged
matthewevans merged 1 commit into
mainfrom
ship/cr733-pop-authority
Jul 26, 2026
Merged

refactor(engine): extract the CR 603.3d uncommitted-trigger removal authority#6664
matthewevans merged 1 commit into
mainfrom
ship/cr733-pop-authority

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 26, 2026

Copy link
Copy Markdown
Member

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 #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.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of triggered abilities that are declined, cannot select a legal mode, or do not require a target.
    • Prevented incomplete trigger entries from remaining in the game state after defensive cleanup.
    • Preserved related stack information while clearing invalid pending triggers consistently.

…uthority

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 #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.
@matthewevans
matthewevans enabled auto-merge July 26, 2026 11:45
@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: 6beab125-829d-4ffa-b49c-6d687ffe6d3c

📥 Commits

Reviewing files that changed from the base of the PR and between 99cb48b and 9f3e9b6.

📒 Files selected for processing (3)
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_modes.rs
  • crates/engine/src/game/stack.rs

📝 Walkthrough

Walkthrough

Changes

The PR adds a centralized helper for removing uncommitted pending trigger entries and uses it across modal-trigger, target-selection, and no-target cleanup paths.

Pending trigger cleanup

Layer / File(s) Summary
Pending-trigger stack cleanup
crates/engine/src/game/stack.rs
Adds conditional top-of-stack removal and clears the associated paid-facts and trigger-event-batch entries while preserving pending_trigger.
Trigger cleanup call sites
crates/engine/src/game/engine.rs, crates/engine/src/game/engine_modes.rs
Replaces repeated defensive cleanup logic with the shared helper across trigger-selection and random-modal resolution branches.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: minion1227

🚥 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 captures the main refactor: centralizing CR 603.3d uncommitted-trigger removal into shared engine authority.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 ship/cr733-pop-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 7be8d00 Jul 26, 2026
15 checks passed
@matthewevans
matthewevans deleted the ship/cr733-pop-authority branch July 26, 2026 12:25
@coderabbitai coderabbitai Bot mentioned this pull request Jul 26, 2026
matthewevans added a commit to minion1227/phase that referenced this pull request Jul 26, 2026
…e-rs#6667)

CR 603.3d: "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."

The engine reaches that removal through the "push first, choose second"
invariant: a triggered ability is PUT on the stack and only then are its choices
gathered, with `pending_trigger_entry` marking the entry whose slots are still
unfilled. Declining an optional MODAL trigger before its mode choice abandons
one, and phase-rs#6664 funnelled all six sites through a single authority. This journals
that authority.

TWO OUTCOMES, both mutating, which is why `removed` is an `Option` rather than a
bare entry. The authority consumes `pending_trigger_entry` UNCONDITIONALLY and
only then decides whether to pop:

  * guard holds — cursor consumed AND the entry leaves with both side tables
  * guard fails — cursor consumed and nothing else, because the cursor outlived
    its entry (another path already removed it)

A command modelling only the first would leave a replay of the second holding a
`pending_trigger_entry` the real execution had cleared — a divergence needing no
forged journal, only an honest replay. The applier therefore also REFUSES a
`removed: None` record whose predecessor still has the entry on top: replaying
there would clear the cursor and strand the entry, a state no execution produces.

The removed side-table VALUES are deliberately not recorded. Contrast
`ResolvedStackEntryFinalizeCommand`, which records `expected_old_paid_facts`
because it INSTALLS a value and must verify what it overwrites. This command only
removes rows keyed on the recorded entry's own id — nothing is installed and
nothing re-derived, so there is no invariant a recorded value would pin, and
carrying `Vec<GameEvent>` batches would widen every journal entry for nothing.

The applier compares the popped entry WHOLE rather than by id. `ObjectId` is
reused across a replay, so an id-only match would discard a divergent entry that
merely shares an id and report success.

Fixture note, because three plausible cards do NOT reach this authority and each
failure is silent. Measured:
  * `you may choose one — ...`  reaches it; entry popped.
  * `choose one — ...`          reaches mid-construction, never removed (no
                                may-offer to decline). Control.
  * `you may destroy target creature`  never reaches it at all — a "you may" on
                                an effect is resolved under CR 608.2d when the
                                ability RESOLVES, so the ability is fully
                                constructed when pushed and declining it makes it
                                do nothing rather than removing it.

Revert probes, each watched go red and restored:
  1. journal call removed -> 0 passed / 3 failed, `left: 0, right: 1`. Proves
     every test reaches the production journal path.
  2. applier matches by id instead of whole entry -> 2 passed / 1 failed, only
     `removal_rejects_a_divergent_predecessor`. Proves that test is
     discriminating rather than incidentally coupled.

CR 603.3d and CR 603.3c grep-verified against docs/MagicCompRules.txt.
Engine integration suite 4064 passed, 0 failed. `clippy -p engine --lib` clean.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.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