Skip to content

fix(engine): release trigger carrier on drop and unstick empty attackers - #7051

Merged
matthewevans merged 2 commits into
mainfrom
ship/fix-engine-trigger-carrier-and-empty-attackers
Aug 6, 2026
Merged

fix(engine): release trigger carrier on drop and unstick empty attackers#7051
matthewevans merged 2 commits into
mainfrom
ship/fix-engine-trigger-carrier-and-empty-attackers

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 6, 2026

Copy link
Copy Markdown
Member

Two independent stalls reported from one game state, both fixed here.
They share a commit because the CR 603.5 prompt-census pin below is only
correct once BOTH are applied.

CR 603.3d — leaked trigger event batch:
drop_mid_construction_pending_trigger released pending_trigger and
pending_trigger_firing but left pending_trigger_event_batch populated.
Because begin_pending_trigger_target_selection re-reads that batch as the
event context for whichever trigger is being constructed, and the pause
paths write it straight back, a single no-legal-target drop latched a dead
event into the game state permanently. resolve_proven_inert_trigger_batch
gates on inert_trigger_batch_state_is_settled, which requires an empty
batch, so batch auto-resolve ("resolve all") stayed disabled for the rest of
the game. The function is now the single authority for releasing all four
in-flight construction cursors, and the four duplicated inline drop blocks
collapse into calls to it.

CR 508.1a — forced empty attack declaration parked the game:
run_auto_pass_loop's DeclareAttackers arm only auto-submitted under
AutoPassMode::UntilTurnBoundary. With no auto-pass configured, a player
controlling zero creatures was parked on a Declare Attackers prompt whose
entire legal-action set was a single no-op submission, and the loop fell
through to _ => break. The arm now also fires when valid_attacker_ids is
empty, mirroring the DeclareBlockers arm, which already auto-submits when
there is nothing to choose. A phase stop still overrides both.

Also updates the CR 603.5 prompt-census pin game/engine.rs:11697 ->
:11712 with a drift-log entry. Pure line movement: the producer is
sha256-identical (8a544e878d3e77fb...), still the only line in the file
matching the producer shape outside #[cfg(test)], still inside
begin_pending_trigger_target_selection, and the set is unchanged at five
producers (total 37, partition 5/7/25).

Both fixes were verified with negative controls in isolated target dirs:
reverted -> red on the intended assertion -> restored -> green.

Summary by CodeRabbit

  • Bug Fixes

    • Automatically advances past attack-declaration prompts when no legal attackers are available, preventing the game from becoming stuck.
    • Fully clears discarded triggered abilities with no legal targets, preventing stale events or actions from affecting later gameplay.
  • Tests

    • Added coverage for automatic handling of empty attack declarations.
    • Added coverage for cleanup when triggered abilities have no valid targets.

Two independent stalls reported from one game state, both fixed here.
They share a commit because the CR 603.5 prompt-census pin below is only
correct once BOTH are applied.

CR 603.3d — leaked trigger event batch:
`drop_mid_construction_pending_trigger` released `pending_trigger` and
`pending_trigger_firing` but left `pending_trigger_event_batch` populated.
Because `begin_pending_trigger_target_selection` re-reads that batch as the
event context for whichever trigger is being constructed, and the pause
paths write it straight back, a single no-legal-target drop latched a dead
event into the game state permanently. `resolve_proven_inert_trigger_batch`
gates on `inert_trigger_batch_state_is_settled`, which requires an empty
batch, so batch auto-resolve ("resolve all") stayed disabled for the rest of
the game. The function is now the single authority for releasing all four
in-flight construction cursors, and the four duplicated inline drop blocks
collapse into calls to it.

CR 508.1a — forced empty attack declaration parked the game:
`run_auto_pass_loop`'s `DeclareAttackers` arm only auto-submitted under
`AutoPassMode::UntilTurnBoundary`. With no auto-pass configured, a player
controlling zero creatures was parked on a Declare Attackers prompt whose
entire legal-action set was a single no-op submission, and the loop fell
through to `_ => break`. The arm now also fires when `valid_attacker_ids` is
empty, mirroring the `DeclareBlockers` arm, which already auto-submits when
there is nothing to choose. A phase stop still overrides both.

Also updates the CR 603.5 prompt-census pin `game/engine.rs:11697` ->
`:11712` with a drift-log entry. Pure line movement: the producer is
sha256-identical (`8a544e878d3e77fb...`), still the only line in the file
matching the producer shape outside `#[cfg(test)]`, still inside
`begin_pending_trigger_target_selection`, and the set is unchanged at five
producers (total 37, partition 5/7/25).

Both fixes were verified with negative controls in isolated target dirs:
reverted -> red on the intended assertion -> restored -> green.
@matthewevans
matthewevans enabled auto-merge August 6, 2026 00:52
@coderabbitai

coderabbitai Bot commented Aug 6, 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: cc0e6a02-9097-41e7-8f66-e4b47c708883

📥 Commits

Reviewing files that changed from the base of the PR and between c7f7e3e and 1c45081.

📒 Files selected for processing (1)
  • crates/engine/src/game/combat.rs

📝 Walkthrough

Walkthrough

The engine now auto-submits empty attacker declarations when no legal attackers exist and no phase stop is active. Pending-trigger cleanup is centralized and clears pending_trigger_event_batch. Regression tests cover both state transitions.

Changes

Engine state-flow fixes

Layer / File(s) Summary
Empty attacker auto-pass
crates/engine/src/game/engine.rs, crates/engine/src/game/engine_auto_pass_decision_tests.rs, crates/engine/src/game/combat.rs
run_auto_pass_loop submits empty attacker declarations when no legal attackers exist, including without configured auto-pass mode. Explicit phase stops still block submission. Combat tests verify selective attacker refresh behavior.
Pending-trigger cleanup
crates/engine/src/game/engine.rs, crates/engine/src/game/engine_trigger_target_tests.rs
drop_mid_construction_pending_trigger now clears pending_trigger_event_batch. Modal, random-selection, delayed-revalidation, and final cleanup paths use the helper. Tests verify that no stale trigger state remains.

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

Possibly related PRs

  • phase-rs/phase#6845: The changes overlap in run_auto_pass_loop, DeclareAttackers handling, and combat regression tests.

Suggested labels: bug

Suggested reviewers: ntindle, nishu-builder

🚥 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 summarizes both primary fixes: releasing the trigger carrier on drop and resolving empty attacker declarations.
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/fix-engine-trigger-carrier-and-empty-attackers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/engine_trigger_target_tests.rs`:
- Around line 1368-1370: Extend the assertions in the pending-trigger cleanup
test to verify that entry_id is absent from both state.stack and
state.stack_trigger_firings, alongside the existing pending-trigger assertions.
Keep the test focused on confirming removal of the illegal triggered ability
from all relevant stack state.

In `@crates/engine/src/game/engine.rs`:
- Around line 11526-11530: Update the annotation associated with the
mid-construction triggered-ability removal logic, retaining CR 603.3c and CR
603.3d while removing the incorrect CR 608.2c citation. Do not alter the
underlying behavior or other explanatory text.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4753ad7e-5eae-4403-9d40-cf7f8db46bfc

📥 Commits

Reviewing files that changed from the base of the PR and between 7f5f0b4 and c7f7e3e.

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

Comment on lines +1368 to +1370
assert!(state.pending_trigger.is_none());
assert!(state.pending_trigger_entry.is_none());
assert!(state.pending_trigger_firing.is_none());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert that the illegal stack entry is removed.

Line 1368 only verifies that the pending cursor is cleared. Add assertions that entry_id is absent from state.stack and state.stack_trigger_firings. Otherwise, this regression passes if cleanup leaves the illegal triggered ability on the stack. CR 603.3d requires the ability to be removed from the stack. (media.wizards.com)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/engine_trigger_target_tests.rs` around lines 1368 -
1370, Extend the assertions in the pending-trigger cleanup test to verify that
entry_id is absent from both state.stack and state.stack_trigger_firings,
alongside the existing pending-trigger assertions. Keep the test focused on
confirming removal of the illegal triggered ability from all relevant stack
state.

Source: Path instructions

Comment on lines +11526 to +11530
/// CR 603.3c + CR 603.3d + CR 608.2c: Single authority for dropping a
/// mid-construction triggered ability — an optional modal declined before mode
/// choice, or CR 603.3d's "if a choice is required when the triggered ability
/// goes on the stack but no legal choices can be made for it ... the ability is
/// simply removed from the stack."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Correct the CR citation.

Line 11526 cites CR 608.2c for a path that removes an ability while it is being put on the stack. CR 608.2c governs following written instructions during resolution. Keep CR 603.3c and CR 603.3d, and remove CR 608.2c from this annotation. (blogs.magicjudges.org)

Based on learnings: “Cite CR 608.2c only when the comment is documenting the resolution of written instructions ‘in order’.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/engine.rs` around lines 11526 - 11530, Update the
annotation associated with the mid-construction triggered-ability removal logic,
retaining CR 603.3c and CR 603.3d while removing the incorrect CR 608.2c
citation. Do not alter the underlying behavior or other explanatory text.

Sources: Path instructions, Learnings

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Generated for head 1c45081a17a0ac4d3f91f17bc7ad5c7f39ae1191.

Parse changes introduced by this PR

✓ No card-parse changes detected.

`debug_set_summoning_sickness_removes_from_valid_attackers` drove its only
eligible attacker sick, emptying `valid_attacker_ids` while parked at the
prompt. The CR 508.1a empty-attackers guard then auto-submitted the sole
legal (empty) declaration and advanced to priority, so the refreshed
`DeclareAttackers` the test exists to inspect no longer existed.

Add a second eligible attacker so the refreshed snapshot stays non-empty
and the declaration is not forced. In real play this transition is
unreachable: declaring attackers is a turn-based action (CR 508.1) with no
priority window mid-declaration, so `valid_attacker_ids` cannot go
non-empty -> empty while a player is parked at the prompt; emptiness is
decided when the prompt is built. Only the debug sandbox can produce it.

The paired assertion is also strictly sharper: it now proves the refresh
dropped *that* creature while retaining the untouched one, which an
emptied set cannot distinguish from a snapshot that simply cleared
everything.
@matthewevans
matthewevans added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit f26f4e3 Aug 6, 2026
13 checks passed
@matthewevans
matthewevans deleted the ship/fix-engine-trigger-carrier-and-empty-attackers branch August 6, 2026 02:19
lgray pushed a commit to lgray/phase that referenced this pull request Aug 6, 2026
…#6826 cost (phase-rs#7053)

phase-rs#7044 refreshed the baseline onto the phase-rs#6826 regression itself
(layers_full_eval 3495 -> 15877). Once phase-rs#7049 fixed that regression the
gate carried ~3x slack -- real cost 5464 against a 15877 baseline under a
~6% band -- so it could no longer catch a new decision-cost regression.

Re-measured on a clean worktree against an immutable card-data snapshot
(scoped card_data_hash 670a4a14, covering 46/46 scenario deck cards):

  counter                             | old    | new    | threshold
  layers_full_eval                    |  15877 |   5464 |      5801
  state_clone_for_legality            |  19342 |  11099 |     11717
  restriction_static_mode_gate_scans  | 155747 | 110891 |    116499
  sba_battlefield_snapshot_builds     |  27462 |  19626 |     20671

scripts/validate-ai-perf-reproducibility.sh: PASSED (margin+band).
25/25 band runs clean, "0 OVER-MARGIN of 29 counters", and
worst_current == baseline on every counter across 125 cold processes.

CI budget: T_run_max 90s * 2.5 + T_build ~630s = ~14.3 min < 25 min.
(The script reported T_build=1s from a warm cache; the 630s figure is the
cold isolated server-release build measured separately, which is the
conservative ceiling the check intends.)

git_sha stamps 7f5f0b4, where the 25-run validation ran. Re-confirmed
against f26f4e3 (current main, including phase-rs#7051 and phase-rs#7017):
0 FAIL, 29 PASS, +0 on every counter.

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