Skip to content

fix(engine): stop search tutors double-firing landfall/ETB observers - #6881

Open
CodeOptimist wants to merge 1 commit into
phase-rs:mainfrom
CodeOptimist:fix/search-landfall-double-fire
Open

fix(engine): stop search tutors double-firing landfall/ETB observers#6881
CodeOptimist wants to merge 1 commit into
phase-rs:mainfrom
CodeOptimist:fix/search-landfall-double-fire

Conversation

@CodeOptimist

@CodeOptimist CodeOptimist commented Aug 2, 2026

Copy link
Copy Markdown

Human intro

See my response in the "Implementation method" section.

LLM PR

Summary

Search tutors that put a card into its destination zone (Nature's Lore, Cultivate, fetchlands) fired every observer triggered ability twice for a single zone change — a single land entry from a search fired each landfall/ETB observer twice. park_search_observer_triggers now drops ZoneChanged occurrences already collected (once) by the zone-change pipeline, mirroring the existing deferred_logical_zone_events guard.

Files changed

  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/search_landfall_double_fire_repro.rs (new)

Track

Developer

LLM

Model: deepseek/deepseek-v4-flash-0731
Tier: Frontier
Thinking: high

Implementation method (required)

Human response

This was not implemented with /engine-implementer; I failed to click the /engine-implementer link or I'd have immediately understood and basically created the equivalent and followed it with minion sessions! I absolutely will next time.

So this did NOT conform to that skill EXCEPT of course the review! Which absolutely was a separate fresh context window.

Sorry for this, I'm still getting used to it!

Note

Any change to crates/engine/ game logic — parser, effects, resolver,
targeting, rules behavior — is expected to go through /engine-implementer.
The "not used" box is for changes that genuinely fall outside that scope.

CR references

  • CR 603.2 — when a game event matches a triggered ability's trigger event, it automatically triggers.
  • CR 603.3b — triggered abilities are placed on the stack the next time a player would receive priority.
  • CR 701.23 — Search (keyword action); search/settle/shuffle protocol.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo clippy -p phase-engine --all-targets -- -D warnings — clean, no warnings.

  • cargo nextest run -p phase-engine — 22,606 integration + 18,229 unit tests pass (all pass; 8 + 6 skipped).

  • New discriminating repro: reverting the dedup makes landfall_fires_once_per_land_etb_from_search_tutor and two_landfall_sources_fire_once_each_for_single_search_land fail (landfall fires 2 and 4 instead of 1 and 2).

  • cargo fmt --all — no changes (already formatted).

  • Reverted-dedup run confirmed the negative (0 passed, 2 failed), then fix restored and re-verified green.

Gate A

Gate A PASS head=f69fc75fcdb4628a2df7ce4f0135a65d6d80b58f base=b9023330ef4b5d091f438eb27fbfbfe321983b85

Anchored on

  • crates/engine/src/game/engine_priority.rs:107 — deferred_logical_zone_events guard: drop ZoneChanged occurrences already represented in state.deferred_triggers so the generic post-priority scan doesn't double-fire; the exact idiom this fix mirrors.
  • crates/engine/src/game/engine_resolution_choices.rs:570 — batch_or_drain_observer_triggers zone_changes_are_logically_owned guard: the sibling observer-parking path that already excludes pipeline-owned ZoneChanged events.

Final review-impl

Final review-impl PASS head=f69fc75fcdb4628a2df7ce4f0135a65d6d80b58f

Right seam — PASS

The fix sits at the correct architectural boundary. park_search_observer_triggers
(engine_resolution_choices.rs) is the function that takes a search tutor's
post-put/shuffle events[events_before_drain..] slice and parks its observers
into state.deferred_triggers. The double-fire bug lived precisely there: the
slice re-contains ZoneChanged occurrences that the zone-change pipeline had
already collected once (append_and_collect_logical_zone_trigger_segment /
complete_logical_zone_trigger_collection), so parking the whole slice
re-collected landfall/ETB observers a second time. This fixes the class of
"search that puts cards into their destination zone" (tutors, Cultivate,
fetchlands, etc.), not a single card.

Idiomatic — PASS (with one refactor)

The fix mirrors the exact precedent guard deferred_logical_zone_events already
present in engine_priority.rs:107. retained_zone_events was aligned to that
precedent's reference-based Vec<_> form (dropping an unnecessary .cloned()
allocation) so the mirror is exact. cargo clippy -p phase-engine --all-targets -- -D warnings is clean.

Edge cases / MTG rules regressions — no regressions

  • The dedup is value-equality on the full ZoneChanged event — it drops only
    an occurrence identical to one already collected. Two distinct lands entering
    from one search (distinct object_id) each fire once — preserved, not
    collapsed.
  • Non-ZoneChanged events (EffectResolved, Shuffle, PlayerPerformedAction,
    ...) remain eligible, so their observers are still parked — no observer loss.
  • Graveyard-/hand-destination search moves aren't battlefield-entrant
    pipeline-owned, so their ZoneChanged won't be in deferred_triggers and
    won't be wrongly swallowed.

Verification

  • Discriminating test confirmed: temporarily reverting the dedup made both
    new tests fail with landfall firing twice (2 and 4 instead of 1 and 2),
    proving the tests exercise the production path and are non-vacuous; fix then
    restored.
  • cargo nextest -p phase-engine: all 22,606 integration + 18,229 unit tests
    pass (including the 72-test search/landfall/ETB surface and the sibling
    issue_5336 Kodama path).
  • cargo clippy -p phase-engine --all-targets -- -D warnings: clean.
  • cargo fmt --all: no changes (already formatted).
  • CR annotations verified against docs/MagicCompRules.txt (603.2 ✓, 603.3b ✓,
    701.23 ✓).

The anchors for the review are: crates/engine/src/game/engine_priority.rs:107 and
crates/engine/src/game/engine_resolution_choices.rs:570 (see "Anchored on").

Claimed parse impact

None.

Scope Expansion

None.

Validation Failures

None.

CI Failures

None.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where landfall and enter-the-battlefield triggers could be processed more than once after searching.
    • Ensured searched lands enter the battlefield once while preserving the correct number of triggers for multiple sources.
  • Tests

    • Added regression coverage for search effects that put lands onto the battlefield and trigger landfall abilities.

## Summary

Search tutors that put a card into its destination zone (e.g. Nature's Lore /
Cultivate / fetchlands putting a land onto the battlefield) fired every
observer triggered ability twice for a single zone change. A single land entry
from a search tutor fired each landfall source twice (and each ETB observer
twice).

## Root cause

`park_search_observer_triggers` (engine_resolution_choices.rs) parks a search's
post-put/shuffle events for the next priority checkpoint. But the `ZoneChanged`
events for cards put into their destination zone by the search's `ChangeZone`
delivery were **already** collected (exactly once) by the zone-change pipeline's
segment/settlement collections
(`append_and_collect_logical_zone_trigger_segment` /
`complete_logical_zone_trigger_collection`). Those same `ZoneChanged`
occurrences still sit in `events[events_before_drain..]`, so parking the whole
slice re-collected each entrant's observers a second time — a single land entry
fired landfall twice.

## Fix

In `park_search_observer_triggers`, drop any `ZoneChanged` occurrence whose
event is already represented in `state.deferred_triggers`, mirroring the
existing `deferred_logical_zone_events` guard in `engine_priority.rs`. The
dedup is value-equality over the full `ZoneChanged` event, so:
- a genuinely new/different zone change still fires its observers exactly once;
- multiple distinct cards entering from one search each fire once (never
  collapsed);
- non-zone events (EffectResolved, Shuffle, PlayerPerformedAction, ...) remain
  eligible and their observers are still parked;
- the reference-based `retained_zone_events` collection matches the
  `deferred_logical_zone_events` idiom exactly (no extra allocation).

## Testing

- New integration repro `search_landfall_double_fire_repro.rs`:
  - single landfall source + single land from a search tutor fires exactly once;
  - two landfall sources + single land fire exactly once each (two total).
- Verified the tests are discriminating: reverting the dedup makes them fail
  with landfall firing twice (2 and 4 instead of 1 and 2).
- `cargo clippy -p phase-engine --all-targets -- -D warnings`: clean.
- Full `cargo nextest -p phase-engine`: 22,606 integration + 18,229 unit tests
  pass, including the 72-test search/landfall/ETB surface and the sibling
  issue_5336 Kodama Nature's Lore path.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The search resolution path now filters deferred ZoneChanged events before parking observer triggers. New integration tests verify that search-tutor land entries produce the expected landfall trigger counts.

Changes

Search trigger deduplication

Layer / File(s) Summary
Filter deferred zone-change events
crates/engine/src/game/engine_resolution_choices.rs
Search observer parking excludes ZoneChanged events already represented in state.deferred_triggers. Other eligible events remain available.
Validate landfall trigger counts
crates/engine/tests/integration/search_landfall_double_fire_repro.rs, crates/engine/tests/integration/main.rs
The integration test harness registers regression tests for search-tutor land entries. The tests verify battlefield placement and landfall trigger counts for one and two sources.

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

Possibly related PRs

  • phase-rs/phase#6661: Both changes modify observer-trigger parking and resumption to avoid lost or duplicated deferred triggers.

Suggested labels: bug

Suggested reviewers: matthewevans

🚥 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 fix for duplicate landfall and ETB observer triggers caused by search tutors.
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 unit tests (beta)
  • Create PR with unit tests

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

Warning

⚠️ This pull request shows signs of AI-generated slop (ai_padded_prose). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

@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/tests/integration/search_landfall_double_fire_repro.rs`:
- Around line 106-111: Retain the IDs returned by both add_creature_from_oracle
calls for “Landfall Scout” and “Landfall Warden” instead of discarding them.
Update the deferred-trigger assertions around the aggregate count to verify each
retained source ID appears exactly once, preventing one source from firing twice
while the other does not.
🪄 Autofix (Beta)

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: 0ae86609-abe8-44ba-9ead-9f3505b0b51a

📥 Commits

Reviewing files that changed from the base of the PR and between b902333 and f69fc75.

📒 Files selected for processing (3)
  • crates/engine/src/game/engine_resolution_choices.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/search_landfall_double_fire_repro.rs

Comment on lines +106 to +111
scenario
.add_creature_from_oracle(P0, "Landfall Scout", 1, 1, LANDFALL_ORACLE)
.id();
scenario
.add_creature_from_oracle(P0, "Landfall Warden", 2, 2, LANDFALL_ORACLE)
.id();

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Assert each landfall source ID.

Lines 106 and 109 discard the source IDs. The aggregate count at lines 129-140 can pass if one source fires twice and the other source does not fire. Retain both IDs. Assert that the matching deferred triggers contain each ID exactly once.

🤖 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/tests/integration/search_landfall_double_fire_repro.rs` around
lines 106 - 111, Retain the IDs returned by both add_creature_from_oracle calls
for “Landfall Scout” and “Landfall Warden” instead of discarding them. Update
the deferred-trigger assertions around the aggregate count to verify each
retained source ID appears exactly once, preventing one source from firing twice
while the other does not.

Source: Path instructions

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Generated for head f69fc75fcdb4628a2df7ce4f0135a65d6d80b58f.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@CodeOptimist

CodeOptimist commented Aug 2, 2026

Copy link
Copy Markdown
Author

Regarding:

⚠️ This pull request shows signs of AI-generated slop (ai_padded_prose). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

I don't disagree. 😂 But I don't know what expectations are regarding (the LLM's generated) source comments. My own personal preferences don't apply here. Perhaps it is far too verbose or perhaps it is fine. 🤷 I can tweak that with direction.

(I personally omit almost all comments that repeat the "what" visible in the code.)

@matthewevans

Copy link
Copy Markdown
Member

Closed as out-of-policy without implementation-diff review. This PR was opened on 2026-08-02, after the 2026-07-24 Frontier-only cutoff in docs/AI-CONTRIBUTOR.md §0.1.1. Its canonical declaration is Model: deepseek/deepseek-v4-flash-0731, which is not in the Frontier-tier table. That policy explicitly requires a Frontier-tier model and says that a PR declaring a non-Frontier model is closed without implementation review.

This is a capacity-policy disposition, not a judgment about the implementation or model generally. Please rerun the work on an accepted Frontier-tier model and open a fresh PR with an accurate canonical Model: line and Tier: Frontier.

@CodeOptimist

CodeOptimist commented Aug 2, 2026

Copy link
Copy Markdown
Author

Closed as out-of-policy without implementation-diff review. This PR was opened on 2026-08-02, after the 2026-07-24 Frontier-only cutoff in docs/AI-CONTRIBUTOR.md §0.1.1. Its canonical declaration is Model: deepseek/deepseek-v4-flash-0731, which is not in the Frontier-tier table. That policy explicitly requires a Frontier-tier model and says that a PR declaring a non-Frontier model is closed without implementation review.

This is a capacity-policy disposition, not a judgment about the implementation or model generally. Please rerun the work on an accepted Frontier-tier model and open a fresh PR with an accurate canonical Model: line and Tier: Frontier.

A very reasonable policy! I mostly ignored AI-CONTRIBUTOR since indicators were that that file is specifically for end-to-end card implementation, and human "out of the loop". Whereas I'm a human "in the loop". (Well whatever miniscule monitoring, interrupting, and guiding I am doing anyway.) 😂

This model is evidently from only a day ago (though I thought there was a good agentic DeepSeek model before? no?) so obviously not in the Frontier table. That's obviously not my call to make so I'll let you handle that. Thanks @matthewevans .

Aside

(As a newcomer to this project it has been pretty confusing whether bits of AI-CONTRIBUTOR are meant to apply in general [given there's already a CLAUDE.md], or only for the "hands off, let's just implement a card" case. With my setup I'm likely a wildcard here so perhaps it's not worth updating, but a clear separation would have helped a lot. I had Gemini Pro Extended try to untangle that before I ever got started. 😅)

Aside, the second

Open-weight models becoming quite good might be very recent. I joined this project due to excitement from this video:
In Search of Frontier AI at Home

Well more like this one but it's out of date: There Has Been a Situation in AI
(I'm cloud-hosted though. Maybe one day the dream!)

Cheers!

Edit: I didn't realize you had a huge amount of CLAUDE skills written up. Those are very nice. So I haven't been following protocol at all. 😬 I'm adding a simple translation layer to minion to use them from now on. 🩵

@matthewevans matthewevans reopened this Aug 2, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer note: override model check - keep in pipeline and continue reviewing.

@matthewevans matthewevans self-assigned this Aug 2, 2026
@matthewevans

Copy link
Copy Markdown
Member

Closed without implementation-diff review.

The PR's canonical Model: line declares deepseek/deepseek-v4-flash-0731. docs/AI-CONTRIBUTOR.md §0.1.1 requires a Frontier-tier model from its exact per-vendor table; that table does not admit this model. The policy was already in force when this PR was opened: commit 2b204dff59c1e928ab67d4683831deb8b8ff9c15 documents the same requirement and predates the PR's 2026-08-02T07:33:28Z creation time.

This is a policy disposition, not a judgment about the implementation. Per §0.1.1, a new PR for this work must be produced with an accepted Frontier-tier model and follow the required engine implementation pipeline.

@matthewevans

Copy link
Copy Markdown
Member

Maintainer correction: the model-only disposition is superseded by the explicit maintainer override. This PR is reopened and returning to implementation review on current head f69fc75; model tier will not be used as a closing criterion.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MED] The two-source regression test can pass with the wrong trigger distribution. Evidence: crates/engine/tests/integration/search_landfall_double_fire_repro.rs:106-111 discard both source IDs, and :129-140 assert only the aggregate count. Why it matters: one source firing twice while the other is missing would still produce a count of two, leaving the reported per-observer duplication class unprotected. Suggested fix: retain the two creature IDs and assert that the matching deferred trigger contexts contain each pending.source_id exactly once.

Reviewed current head f69fc75. This request is implementation/test-coverage only; the maintainer override supersedes the previous model-only closure.

@matthewevans matthewevans added the bug Bug fix label Aug 2, 2026
@matthewevans matthewevans removed their assignment Aug 2, 2026
@matthewevans

Copy link
Copy Markdown
Member

Edit: I didn't realize you had a huge amount of CLAUDE skills written up. Those are very nice. So I haven't been following protocol at all. 😬 I'm adding a simple translation layer to minion to use them from now on. 🩵

Yes! Tried to make it really easy for LLMs to approach doing any parser/engine changes. They all use the same skill (even for adding new cards). I'd try directing DeepSeek to "use the .agents/skills/engine-implementer skill" (the claude skills are symlinked to .agents) and inform it the referenced skills are in the .agents/skills directory as well. I'm curious how it handles running through the actual skill pipeline!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants