Skip to content

fix(engine): stop search deliveries double-collecting ETB/landfall triggers - #7058

Merged
matthewevans merged 1 commit into
mainfrom
ship/search-delivery-observer-dedup
Aug 6, 2026
Merged

fix(engine): stop search deliveries double-collecting ETB/landfall triggers#7058
matthewevans merged 1 commit into
mainfrom
ship/search-delivery-observer-dedup

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 6, 2026

Copy link
Copy Markdown
Member

A library-search delivery (fetchlands, Cultivate, Harrow, every
SearchLibrary -> ChangeZone card) ran TWO trigger collectors over the same
GameEvent::ZoneChanged occurrence:

  1. the logical zone-change owner -- change_zone.rs (targeted) or
    zone_pipeline.rs (partition) -> complete_logical_zone_trigger_collection
    -> state.deferred_triggers
  2. park_search_observer_triggers, which re-scanned the raw action slice and
    called collect_triggers_into_deferred again with no filter but PhaseChanged

triggers.rs's per-event dedup (registered_this_event) is a HashSet allocated
inside the event loop, so it cannot see across the two passes. A cracked
fetchland fired a landfall observer twice and an Undercity Sewers ETB twice; the
same land merely PLAYED fired once, because handle_play_land calls
zone_pipeline::deliver directly and allocates no logical group -- one collector,
not two.

LogicalZoneChangeGroup::append_delivery_events retains EVERY ZoneChanged in the
slice it is handed, so ownership is per-SLICE: a collector whose slice is
exactly one owner's completion slice may blanket-drop ZoneChanged, and that is
what batch_or_drain_observer_triggers (zone_changes_are_logically_owned) and the
resumed ChangeZone drain in effects/mod.rs correctly do. park's slice spans a
whole continuation drain and can hold zone changes no owner allocated a group
for, so it cannot. It must consult the authority instead.

Extract that authority -- the consumed-occurrence ledger plus the ZoneChanged
values already queued in deferred_triggers -- into
triggers::filter_already_collected_trigger_events_from and route BOTH the
generic priority scan and park through it.

The queued witness is consumed one-for-one rather than by set membership. That
is a BOUND, not a proof: deferred_triggers holds one context per matching
observer, not one entry per occurrence, so N observers of one occurrence
contribute N copies of its value. The new filter therefore removes at most
min(queued_copies, slice_copies), and it is NOT occurrence-exact -- it does not
by itself discharge CR 603.2c's second sentence. No occurrence-exact witness is
reachable here: the only exact record is
LogicalZoneChangeGroup::all_origin_occurrences, and a completed owner's group is
a caller-owned local that is gone before the collector runs (GameState holds a
group only inside the two paused frames).

The HEAD comparison is per-caller. At the generic priority scan the new filter
consumes at most what the set-membership filter it replaces consumed, so that
caller can never newly hide a trigger. At park there was no ZoneChanged filter
at all, so park becoming more suppressive IS the fix -- and the bound above is
therefore also a new residual there: a byte-identical ZoneChanged that no owner
grouped, sharing a slice with an owner-collected copy two observers saw, is
suppressed where HEAD fired it. That residual is bounded by byte-identical
ZoneChanged duplicates being unreachable inside one collector slice, and is
pinned executably by a unit row.

Deliberately does NOT pair mark_ with the four unpaired complete_ owners
(change_zone.rs x2, engine_resolution_choices.rs x2). Claiming an occurrence
also hides it from check_delayed_triggers (engine_priority.rs), which would
silently kill the CR 603.7b leaves-the-battlefield delayed family -- the
reanimation-aura sacrifice from the Animate Dead class and the earthbend
WhenDiesOrExiled gate. A new regression guard pins it, and was watched go red
under the rejected shape.

Park intentionally defers its observers to the next priority checkpoint
(issue #5336), so the new tests pass priority before asserting -- asserting at
the end of the parked action measures nothing, because
WaitingForWithParkedObservers sets skip_deferred_trigger_drain and both test
drivers break on an empty stack.

CR 603.2 / CR 603.2c / CR 603.3b / CR 603.7b / CR 704.5b
(verified in docs/MagicCompRules.txt at lines 2561 / 2567 / 2586 / 2616 / 5494).

Summary by CodeRabbit

  • Bug Fixes

    • Prevented duplicate observer and zone-change triggers during search deliveries and post-action processing.
    • Preserved correct trigger behavior for fetchlands, landfall abilities, delayed sacrifices, and fail-to-find searches.
    • Ensured multiple valid trigger occurrences are retained while already-processed events are excluded.
  • Tests

    • Added regression coverage for search delivery, replacement effects, multiple landfall events, and non-battlefield searches.
  • Documentation

    • Clarified how previously processed trigger events are tracked and filtered.

…iggers

A library-search delivery (fetchlands, Cultivate, Harrow, every
SearchLibrary -> ChangeZone card) ran TWO trigger collectors over the same
GameEvent::ZoneChanged occurrence:

  1. the logical zone-change owner -- change_zone.rs (targeted) or
     zone_pipeline.rs (partition) -> complete_logical_zone_trigger_collection
     -> state.deferred_triggers
  2. park_search_observer_triggers, which re-scanned the raw action slice and
     called collect_triggers_into_deferred again with no filter but PhaseChanged

triggers.rs's per-event dedup (registered_this_event) is a HashSet allocated
inside the event loop, so it cannot see across the two passes. A cracked
fetchland fired a landfall observer twice and an Undercity Sewers ETB twice; the
same land merely PLAYED fired once, because handle_play_land calls
zone_pipeline::deliver directly and allocates no logical group -- one collector,
not two.

LogicalZoneChangeGroup::append_delivery_events retains EVERY ZoneChanged in the
slice it is handed, so ownership is per-SLICE: a collector whose slice is
exactly one owner's completion slice may blanket-drop ZoneChanged, and that is
what batch_or_drain_observer_triggers (zone_changes_are_logically_owned) and the
resumed ChangeZone drain in effects/mod.rs correctly do. park's slice spans a
whole continuation drain and can hold zone changes no owner allocated a group
for, so it cannot. It must consult the authority instead.

Extract that authority -- the consumed-occurrence ledger plus the ZoneChanged
values already queued in deferred_triggers -- into
triggers::filter_already_collected_trigger_events_from and route BOTH the
generic priority scan and park through it.

The queued witness is consumed one-for-one rather than by set membership. That
is a BOUND, not a proof: deferred_triggers holds one context per matching
observer, not one entry per occurrence, so N observers of one occurrence
contribute N copies of its value. The new filter therefore removes at most
min(queued_copies, slice_copies), and it is NOT occurrence-exact -- it does not
by itself discharge CR 603.2c's second sentence. No occurrence-exact witness is
reachable here: the only exact record is
LogicalZoneChangeGroup::all_origin_occurrences, and a completed owner's group is
a caller-owned local that is gone before the collector runs (GameState holds a
group only inside the two paused frames).

The HEAD comparison is per-caller. At the generic priority scan the new filter
consumes at most what the set-membership filter it replaces consumed, so that
caller can never newly hide a trigger. At park there was no ZoneChanged filter
at all, so park becoming more suppressive IS the fix -- and the bound above is
therefore also a new residual there: a byte-identical ZoneChanged that no owner
grouped, sharing a slice with an owner-collected copy two observers saw, is
suppressed where HEAD fired it. That residual is bounded by byte-identical
ZoneChanged duplicates being unreachable inside one collector slice, and is
pinned executably by a unit row.

Deliberately does NOT pair mark_ with the four unpaired complete_ owners
(change_zone.rs x2, engine_resolution_choices.rs x2). Claiming an occurrence
also hides it from check_delayed_triggers (engine_priority.rs), which would
silently kill the CR 603.7b leaves-the-battlefield delayed family -- the
reanimation-aura sacrifice from the Animate Dead class and the earthbend
WhenDiesOrExiled gate. A new regression guard pins it, and was watched go red
under the rejected shape.

Park intentionally defers its observers to the next priority checkpoint
(issue #5336), so the new tests pass priority before asserting -- asserting at
the end of the parked action measures nothing, because
WaitingForWithParkedObservers sets skip_deferred_trigger_drain and both test
drivers break on an empty stack.

CR 603.2 / CR 603.2c / CR 603.3b / CR 603.7b / CR 704.5b
(verified in docs/MagicCompRules.txt at lines 2561 / 2567 / 2586 / 2616 / 5494).
@matthewevans
matthewevans enabled auto-merge August 6, 2026 12:25
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The engine adds occurrence-aware filtering for collected trigger events. Priority scans and search observer parking use the shared filter. Unit and integration tests cover duplicate zone changes, search deliveries, delayed triggers, and fail-to-find searches.

Changes

Trigger deduplication

Layer / File(s) Summary
Shared collected-event filter
crates/engine/src/game/triggers.rs, crates/engine/src/types/game_state.rs, crates/engine/src/game/triggers_dedup_regression_tests.rs
Adds filtering for consumed occurrences and queued ZoneChanged witnesses. Documents the consumed-event ledger and tests occurrence-count behavior.
Trigger collection call sites
crates/engine/src/game/engine_priority.rs, crates/engine/src/game/engine_resolution_choices.rs
Uses the shared filter during priority scans and search observer parking.
Search delivery regression coverage
crates/engine/tests/integration/main.rs, crates/engine/tests/integration/search_delivery_observer_dedup.rs
Adds integration coverage for search delivery, replacement choices, multiplicity, delayed sacrifice, and fail-to-find cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SearchEffect
  participant SearchObserverParking
  participant EventFilter
  participant DeferredTriggers
  participant TriggerResolution
  SearchEffect->>SearchObserverParking: deliver continuation events
  SearchObserverParking->>EventFilter: filter collected events
  EventFilter->>DeferredTriggers: inspect consumed occurrences and queued witnesses
  DeferredTriggers-->>EventFilter: return matching collection state
  EventFilter-->>SearchObserverParking: return remaining events
  SearchObserverParking->>DeferredTriggers: queue observer triggers
  DeferredTriggers->>TriggerResolution: drain deferred triggers
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: andriypolanski, lgray, kiannidev

🚥 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 and concisely describes the main fix: preventing duplicate ETB and landfall trigger collection during search deliveries.
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 ship/search-delivery-observer-dedup

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: 1

🤖 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/triggers_dedup_regression_tests.rs`:
- Around line 3803-3838: Update the trigger occurrence model so distinct
equal-valued ZoneChanged occurrences remain distinguishable: carry an
action-scoped occurrence identifier through PendingTriggerContext and
ConsumedTriggerEventOccurrence, and have
filter_already_collected_trigger_events_from consume each occurrence only once
regardless of observer count. Change
owner_collected_filter_counts_contexts_not_occurrences to expect one survivor,
and add a production-pipeline regression covering separate equal-valued
occurrences and separate trigger processing.
🪄 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: 13565a15-b37b-4594-8381-e8a86e246f6a

📥 Commits

Reviewing files that changed from the base of the PR and between a0d0b05 and 877c6e7.

📒 Files selected for processing (7)
  • crates/engine/src/game/engine_priority.rs
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/triggers_dedup_regression_tests.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/search_delivery_observer_dedup.rs

Comment thread crates/engine/src/game/triggers_dedup_regression_tests.rs
@matthewevans
matthewevans added this pull request to the merge queue Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Generated for head 877c6e78d0a104bf442fd95f9fdb797b634f87ec.

Parse changes introduced by this PR

✓ No card-parse changes detected.

Merged via the queue into main with commit 84a4d01 Aug 6, 2026
13 checks passed
@matthewevans
matthewevans deleted the ship/search-delivery-observer-dedup branch August 6, 2026 13:07
matthewevans added a commit to dan-blanchard/phase that referenced this pull request Aug 7, 2026
…hase-rs#7069)

Follow-up to phase-rs#7058. A review comment on that PR asked to carry an
action-scoped occurrence identifier through PendingTriggerContext and
ConsumedTriggerEventOccurrence so that two equal-valued ZoneChanged
entries in one collector slice are consumed as separate occurrences.

That remedy is rejected, on evidence:

  * The identity already exists. ZoneChangeRecord::turn_zone_change_index
    is a per-turn monotonic index assigned by restrictions::record_zone_change
    (its sole grower), and it participates in GameEvent's derived PartialEq.
    Two ZoneChanged values from distinct occurrences therefore cannot be
    byte-identical. Two that ARE byte-identical denote one occurrence
    emitted twice, which is exactly what the filter should collapse.

  * The finding's evidence was a #[cfg(test)] fixture. test_minimal pins the
    index at 0 for both events; that is a property of the fixture, not a
    production contract.

  * The remedy would regress CR 104.4b. deferred_triggers IS compared inside
    GameState::eq, so injecting a fresh per-instance id there makes two
    otherwise-identical loop iterations compare unequal: the mandatory-loop
    draw never fires, the engine burns auto_pass_loop_max_iterations and
    downgrades to a CR 732.2 halt. This is the same shape that pip_id on
    ManaUnit caused. The sibling ledger is safe only because
    consumed_before_priority_trigger_events is excluded from equality --
    an exclusion deferred_triggers does not have.

So the deliverable is to make the shipped doc comment's asserted premise an
enforced one. No type changes; GameState::eq, normalize_for_loop and
loop_states_equal are untouched, and ZoneChangeRecord keeps its derive --
keeping it is the mechanism. CR 104.4b is discharged vacuously.

Three regression rows pin the invariant, each verified to fail for its
stated mechanism when the corresponding authority is falsified:

  * U5 pins the equality link at the filter authority itself; red when
    ZoneChangeRecord's PartialEq is hand-written to skip the index.
  * U6 pins the within-Library reposition family (CR 400.7 zero-bump, per
    CR 701.20b a reveal is not a zone change) at the real production mover;
    red when the zones.rs:1820 stamp is zeroed.
  * N5 pins allocator-to-event fidelity across the search-delivery park
    path, asserting pre-drain by design; red when the zones.rs:826 stamp is
    zeroed. It must not pass priority -- a pass runs the drain and makes the
    assertion vacuous.

Also corrects the harness note in search_delivery_observer_dedup.rs, whose
universal was already inaccurate before this change: it is scoped to
park-path rows asserting what the parked observers did, and N4/N5 assert
pre-drain deliberately.

Four unrelated engine defects surfaced while establishing the above and are
filed rather than fixed here: phase-rs#7063, phase-rs#7064, phase-rs#7065, phase-rs#7066.

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