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
@coderabbitai coderabbitai Bot mentioned this pull request Aug 10, 2026
4 tasks
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