Skip to content

fix(engine): settle the answered copy-target choice so liminal copy-token ETB observers fire - #7020

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
lgray:fix/liminal-copy-token-etb-observers
Aug 5, 2026
Merged

fix(engine): settle the answered copy-target choice so liminal copy-token ETB observers fire#7020
matthewevans merged 1 commit into
phase-rs:mainfrom
lgray:fix/liminal-copy-token-etb-observers

Conversation

@lgray

@lgray lgray commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

A token that enters the battlefield as a copy via a mid-entry choice (Embalm-as-copy is the live instance) was observed by zero enters-the-battlefield abilities. handle_copy_target_choice's liminal-resume branch never cleared the already-answered CopyTargetChoice, so both of its tails echoed the spent prompt, the action never settled to WaitingFor::Priority, apply_action skipped run_post_action_pipeline — and that pipeline is the only CR 603.6a trigger scan on this route. One statement clears the spent prompt; observers now fire exactly once.

Predecessor context: this is the follow-up lane to #7012 (which fused battlefield-entry record+emit into one authority). #7012 moved where the entry pair is recorded and emitted; this PR fixes when the action carrying it settles, which is what decides whether anything scans it. The two are independent — this defect reproduces identically before and after #7012.

Files changed

  • crates/engine/src/game/engine_replacement.rs — the fix: clear the answered CopyTargetChoice at the top of the liminal-resume branch, above the Token/Meld split.
  • crates/engine/tests/integration/liminal_copy_token_etb_observers.rs — new suite (7 tests) covering fired-exactly-once, multi-observer ordering, opponent-controlled observer, three unchanged controls, and a premise-break arm.
  • crates/engine/tests/integration/main.rsmod registration for the new suite.
  • crates/engine/tests/integration/token_zone_change_index.rs — corrected a now-false doc claim, and tightened a drive.prompts[..2] slice assertion to the whole vector (that slice existed only to accommodate the stale prompt this PR removes).

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

Disclosed deviation: the flow used was planner subagent → plan document → engine-implementation-executor → independent adversarial review → review-impl at the committed head. The canonical checkpoint-mode receipt artifact was not generated, so this is the skill's agent flow without its receipt. Stated rather than claimed clean.

CR references

Added on the fix:

  • CR 614.12a"If a replacement effect that modifies how a permanent enters the battlefield requires a choice, that choice is made before the permanent enters the battlefield." The choice is a single pre-entry event, so the prompt that asked for it is spent and cannot be the action's outcome.
  • CR 603.3"Once an ability has triggered, its controller puts it on the stack as an object that's not a card the next time a player would receive priority." The ETB abilities are owed a priority boundary; an action echoing an answered prompt never reaches one.

Touched in test documentation: CR 400.7, CR 603.2c, CR 603.3b, CR 603.6a, CR 608.2i, CR 616.1, CR 702.49c, CR 704.5f, CR 707.2c.

Every number above was verified two-step (resolves in docs/MagicCompRules.txt and its text supports the site). CR 608.2i is a soft over-citation in a test doc — it describes the purpose of the ledger the assertion reads rather than a rule the test implements. Disclosed, left in place.

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 test -p phase-engine — 18514 lib + 12 + 9 + 4550 integration passed, 0 failed

  • cargo test -p phase-engine --test integration liminal_copy_token11 passed, 0 failed, re-run at the rebased head and again at the committed head

  • cargo test -p phase-ai — 2106 passed, 0 failed

  • cargo clippy --workspace --all-targets -- -D warnings — rc=0

  • cargo fmt --all --check — rc=0

  • ./scripts/check-parser-combinators.sh — Gate G PASS, Gate A PASS, Gate P PASS

Mutant evidence (per-assertion, two-sided, flipping different rows):

  • MUTANT-DROP (delete the fix statement) — 4 failures, firing the prompt-vector assertions; drive.prompts returns to 3 entries with the stale third; controls and every premise-break row stay green.
  • MUTANT-TRIVIALIZE (fix kept, plus the flushed ZoneChanged re-parked into deferred_entry_events) — 2 failures, firing the life-magnitude assertions (L1 and L3 each read 2 instead of 1); every prompt guard and both controls stay green.
  • Premise-break arm (p1_break_every_delta_row_reads_zero, shipped in the suite) — with the observer staged without its Oracle text, every delta row reads 0 including both controls, while the entry pins still hold. No row can pass while reading something other than the observer.

Exit-path enumeration for the fix's safety claim — all 9 exits of the edited branch instrumented over the full suite, both arms: 16 entries → 16 exits in both (equal counts prove no error return escaped). The 4 exits carrying a genuine pause are byte-identical with and without the fix; only the 12 that returned the spent prompt now return Priority. No path that owed a player prompt returns one fewer.

Honest residual: three matches!(state.waiting_for, Priority) gates inside drain_pending_continuation change from skipped to run, each additionally gated on a frame that is absent on this route. No fixture reaches them, so this is recorded as unverified, not verified-safe.

Gate A

Gate A PASS head=33879bfcde64fb4dcc78d0741140e2feba41ed87 base=4b34e5465eafa94bcd49dfe1a9275968be4300dc

Note: the gate resolves its base fork-relatively, so the printed base is older than this branch's actual base (acfeaf0786c7f1b39d0226ed965981348a2a5dd3). The examined range is therefore a superset of this diff — conservative, not vacuous. Reported exactly as the script printed it. This diff contains no parser changes.

Anchored on

  • crates/engine/src/game/engine_replacement.rs:1576 — the same clear inside handle_persist_chosen_attribute_choice, which answers a sibling mid-entry choice on the same handler family.
  • crates/engine/src/game/engine_replacement.rs:1958 — this same handler's own non-liminal tail, which already clears the prompt before settling.
  • crates/engine/src/game/effects/token.rs:1446continue_liminal_copy_token_batch opens by setting Priority in exactly this shape.

(Line numbers generated by grep -n/awk against this branch's tree at the committed head.)

Stamp

HEAD_SHA=33879bfcde64fb4dcc78d0741140e2feba41ed87
BASE_SHA=acfeaf0786c7f1b39d0226ed965981348a2a5dd3
MEASURE_EPOCH=1785906844
DIRTY_TRACKED_FILES=0 (working tree clean for tracked paths at stamp time; the sha256 set is therefore empty by construction, not omitted)

Per-figure predicates: life deltas are runner.life(seat) after minus before, same test; prompt counts are prompts raised and answered by a runner.act call — not instrument lines; observers are constructed with add_creature_from_oracle (a graft-based observer measures a different quantity and is not used here).

Final review-impl

Final review-impl PASS head=33879bfcde64fb4dcc78d0741140e2feba41ed87

Review lineage, disclosed because the first pass did not pass. review-impl round 1 (head db547bb9b) returned FAIL with three MED findings, none in the engine change: a proposed pinning test that was physically impossible (it named ninjutsu, which never creates a token) with an unsupporting CR citation; a reachability claim ("reached by no fixture") that a probe disproved; and a vacuous assertion in the premise-break arm, labelled as an axis but run on a board whose live counterpart reads the same value. All three were in prose or test-labelling written after the substantive review, and all three were fixed. Round 2 (this head) re-verified the fixes, hunted for siblings of both defect classes, re-checked all 8 cards against Scryfall and every CR citation, independently reproduced the MUTANT-DROP signature, and returned PASS with zero findings.

Round 2 also measured a structural prediction of its own rather than reporting it: it expected the "nothing is stranded" claim to be impossible from code-reading, built a positive-controlled probe, and found round 1 correct and itself wrong. Recorded because it is the reason that claim is trustworthy.

Claimed parse impact

None.

Scope Expansion

One, ratified: the drive.prompts[..2] slice assertion in token_zone_change_index.rs was tightened to the whole vector. That slice existed only as an accommodation of the stale prompt this PR removes — of the 6 route tests in that file asserting drive.prompts, it was the only one asserting a slice rather than the whole vector, which is the tell that led to this root cause. Closing it is in-scope; the new suite's ordering-prompt assertion inherits its diagnostic role.

Validation Failures

None.

CI Failures

None.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed copy-token actions that could stall after a target was selected.
    • Ensured battlefield entry effects and related triggers resolve correctly after copy-token actions.
    • Preserved correct behavior for regular entries, non-entry choices, and opponent-triggered effects.
  • Tests

    • Added comprehensive coverage for copy-token entry effects, trigger ordering, and prompt resolution.

…oken ETB observers fire

A token entering the battlefield as a copy via a mid-entry choice (Embalm-as-copy)
was observed by zero enters-the-battlefield abilities.

`handle_copy_target_choice`'s liminal-resume branch never cleared the
already-answered `CopyTargetChoice`, so both of its tails echoed the spent prompt
via `if !Priority { return waiting_for }`. The action therefore never settled,
`apply_action` skipped `run_post_action_pipeline`, and the CR 603.6a entry pair
flushed by `finish_copy_target_choice_entry` never reached a priority boundary to
be scanned. The client also re-answered the spent prompt — the stale second
`CopyTargetChoice` disclosed in `token_zone_change_index.rs`, which is why that
route's reach-guard could only assert a `[..2]` slice of its prompt vector.

CR 614.12a makes the choice a single pre-entry event, so the prompt that asked for
it is spent; CR 603.3 owes the triggered abilities the priority boundary the echo
denied them. The clear is placed above the Token/Meld split because both
sub-branches share the echo tail, and mirrors three existing clears: this
handler's own non-liminal tail, `handle_persist_chosen_attribute_choice`, and
`effects::token::continue_liminal_copy_token_batch`.

Diagnosis note, because the obvious explanation was wrong: the deferred-entry
capture is empty on this route, but that is not the cause. It is empty on every
liminal copy-token route, including three sibling routes whose observers fire
correctly. A condition present in both the failing and the passing arms is not the
differentiator — settlement is. Instrumenting all nine exits of the branch over
the full suite gives 16 entries and 16 exits in both arms, with the four exits
carrying a genuine pause byte-identical; only the twelve that returned the spent
prompt now return `Priority`.

Tests: new `liminal_copy_token_etb_observers` suite covering the fired-exactly-once
row, the multi-observer ordering prompt, an opponent-controlled observer, and
unchanged controls for the permanent-spell copy route, a plain creature entry, and
a non-entry copy choice. Includes a premise-break arm that stages the observer
without its Oracle text and asserts every delta row — both controls included —
reads zero while the entry pins still hold, so no row can pass while reading
something other than the observer.

Assisted-by: ClaudeCode:claude-opus-5
@lgray
lgray requested a review from matthewevans as a code owner August 5, 2026 05:15
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 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: 80b77564-73be-4631-acc4-0a86a78526c1

📥 Commits

Reviewing files that changed from the base of the PR and between acfeaf0 and 33879bf.

📒 Files selected for processing (4)
  • crates/engine/src/game/engine_replacement.rs
  • crates/engine/tests/integration/liminal_copy_token_etb_observers.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/token_zone_change_index.rs

📝 Walkthrough

Walkthrough

The engine now clears the consumed CopyTargetChoice prompt before liminal completion. New integration tests cover Embalm-as-copy entry, ETB observers, control routes, broken observers, and final prompt settlement.

Changes

Liminal copy-token battlefield entry

Layer / File(s) Summary
Reset consumed copy-target prompts
crates/engine/src/game/engine_replacement.rs
Resets waiting_for to Priority before liminal token or meld completion.
Build observer-flow integration fixtures
crates/engine/tests/integration/liminal_copy_token_etb_observers.rs, crates/engine/tests/integration/main.rs
Adds scenario fixtures, measurement helpers, prompt drivers, board construction, assertions, and test-module registration.
Validate copy-token and control scenarios
crates/engine/tests/integration/liminal_copy_token_etb_observers.rs
Tests controller and opponent observers, ordering prompts, permanent-spell copies, declined replacements, and non-entry copies.
Validate regression and premise controls
crates/engine/tests/integration/liminal_copy_token_etb_observers.rs, crates/engine/tests/integration/token_zone_change_index.rs
Tests broken-observer behavior and verifies that the copy-target choice is the final prompt during same-action settlement.

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

Possibly related PRs

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 describes the main fix: settling the answered copy-target choice so liminal copy-token ETB observers fire.
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

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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Generated for head 33879bfcde64fb4dcc78d0741140e2feba41ed87.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans matthewevans self-assigned this Aug 5, 2026

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

Approved. Current head 33879bf clears the consumed copy-target choice before both liminal completion tails; the current-head runtime suite exercises Embalm copy entry, ETB observer/order/controller behavior, and the prior stale-prompt regression. The SHA-bound parse artifact reports no parse changes.

@matthewevans matthewevans added the bug Bug fix label Aug 5, 2026
@matthewevans
matthewevans added this pull request to the merge queue Aug 5, 2026
@matthewevans matthewevans removed their assignment Aug 5, 2026
Merged via the queue into phase-rs:main with commit dd6ab09 Aug 5, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants