Skip to content

fix(engine): keep Portent exile picks out of the graveyard (#6498)#6545

Merged
matthewevans merged 16 commits into
phase-rs:mainfrom
andriypolanski:fix/6498-portent-of-calamity-selection
Jul 24, 2026
Merged

fix(engine): keep Portent exile picks out of the graveyard (#6498)#6545
matthewevans merged 16 commits into
phase-rs:mainfrom
andriypolanski:fix/6498-portent-of-calamity-selection

Conversation

@andriypolanski

@andriypolanski andriypolanski commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes #6498

Summary

Discord report: Portent of Calamity did not let you keep selected revealed cards — they went to the graveyard.

Oracle: Reveal the top X cards … For each card type, you may exile a card of that type from among them. Put the rest into your graveyard. …

Root cause

  1. "Put the rest into your graveyard" was parsed as ChangeZoneAll { Exile → Graveyard, TrackedSet }. After ForEachCategory exile, the tracked set is the exiled picks — so selecting a card immediately dumped it to the graveyard.
  2. Dig→RevealTop demotion collapsed non-fixed counts (X) to 1.

Changes

  • Keep dynamic-count reveal Digs as reveal-only peeks (keep_count: 0) instead of collapsing X to RevealTop { count: 1 }.
  • After ForEachCategory exile, "put the rest" emits ChangeZoneAll { Library → Graveyard, LastRevealed } so only non-exiled revealed cards go to the graveyard.
  • Publish CardsRevealed from reveal-only Digs into the chain tracked-set path; ForEachCategory pool falls back to last_revealed_ids.

Test plan

  • cargo test -p engine --lib -- portent
  • cargo test -p engine --test integration -- portent
  • CI green

Summary by CodeRabbit

  • Bug Fixes
    • Preserved dynamic reveal quantities for “Dig” effects, avoiding incorrect fixed-count degeneration.
    • Improved reveal-only “Dig” resolution to correctly record public reveals vs controller-scoped private peeks and maintain the proper reveal window.
    • Fixed “put the rest” behavior after reveal/exile chains, including correct support for “rest of the exiled cards” and consistent tracked exiled-card cleanup (including optional casting paths).
  • Tests
    • Added integration coverage for Portent of Calamity to validate oracle parsing and end-to-end resolution for dynamic reveals and chained “rest” actions.

…6498)

Model put-the-rest after ForEachCategory as LastRevealed still in the library,
and preserve dynamic X on reveal Digs instead of collapsing to RevealTop{1}.

Co-authored-by: Cursor <cursoragent@cursor.com>
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 23, 2026
@superagent-security

Copy link
Copy Markdown

🚨 Contributor flagged. Click here for more info: Superagent Dashboard

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Oracle parsing and runtime resolution now preserve dynamic reveal counts, expose reveal-only Dig cards for selection, track exile causes, and move remaining revealed or exiled cards to the correct zones. Integration tests cover Portent of Calamity parsing and resolution.

Changes

Portent of Calamity behavior

Layer / File(s) Summary
Oracle effect modeling
crates/engine/src/parser/oracle_effect/assembly.rs, crates/engine/src/parser/oracle_effect/sequence.rs, crates/engine/src/parser/oracle_effect/imperative.rs, crates/engine/src/parser/oracle_target.rs
Dynamic reveal quantities remain runtime-resolvable, “put the rest” clauses target revealed-library cards, and “the rest of the exiled cards” targets the exile-caused tracked set.
Reveal state and zone resolution
crates/engine/src/game/effects/dig.rs, crates/engine/src/game/effects/mod.rs, crates/engine/src/game/effects/choose_from_zone.rs
Reveal-only Dig records reveal state and events, category pools use the latest revealed IDs, and selected exiles publish Exiled causes.
Behavior validation and test wiring
crates/engine/tests/integration/issue_6498_portent_of_calamity.rs, crates/engine/tests/integration/main.rs
Integration tests verify dynamic X reveals, per-type exile selection, remaining-card movement, and final exiled-card handling.

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

Sequence Diagram(s)

sequenceDiagram
  participant Spell
  participant Dig
  participant CategorySelection
  participant ZoneState
  Spell->>Dig: resolve dynamic reveal
  Dig->>ZoneState: record LastRevealed and CardsRevealed
  ZoneState->>CategorySelection: expose revealed pool
  CategorySelection->>ZoneState: exile selected cards with causes
  ZoneState->>ZoneState: move remaining revealed cards
  ZoneState->>ZoneState: move remaining exiled cards to hand
Loading

Possibly related PRs

🚥 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 the Portent of Calamity fix to keep exile picks out of the graveyard.
Linked Issues check ✅ Passed The changes address #6498 by preserving revealed-card selection, exiling chosen cards, and moving only the rest to the graveyard and hand as specified.
Out of Scope Changes check ✅ Passed The modified parser, effect resolution, and tests all appear directly related to implementing Portent of Calamity's oracle behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@matthewevans matthewevans self-assigned this Jul 23, 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.

Verdict: Blocked — the first Put the rest cleanup is separated correctly, but the card's final Put the rest of the exiled cards into your hand still has no stable binding to only the selected exile set.

🔴 Blocker

[HIGH] The final cleanup is routed through the generic the rest tracked-set path after the new Library→Graveyard cleanup has added the rejected reveal cards to that same chain set. Evidence: choose_from_zone.rs:284 creates the fresh per-category set and choose_from_zone.rs:524 publishes the exile picks; the new sequence.rs:3995 adds a LastRevealed Library→Graveyard move; and effects/mod.rs:5028 deliberately extends, rather than replaces, that chain set. The generic target parser then consumes only the the rest prefix as TrackedSet (oracle_target.rs:1030), losing the of the exiled cards qualifier. Why it matters: Portent's actual Oracle text requires the final instruction to move only the uncast cards exiled by the preceding category choices; it must not also select cards that were just moved to the graveyard. This violates the card's written sequence (CR 608.2c) and reveal-zone behavior (CR 701.20b). Suggested fix: model the two distinct remainder sets explicitly—revealed cards still in the library for the first cleanup, and the category-exiled set (minus a cast card) for the final cleanup—rather than reusing a chain-wide TrackedSet. Add a runtime regression with five revealed cards, four selected/exiled and one left for the graveyard, then decline or cast the free spell and assert that only the remaining exiled cards go to hand.

[MEDIUM] The new runtime test cannot expose that full-card failure: it reveals exactly four differently typed cards and selects all four, then stops as soon as the free-cast prompt appears. Evidence: issue_6498_portent_of_calamity.rs:119 creates only four cards, and issue_6498_portent_of_calamity.rs:144 exits after the category choices. Why it matters: neither remainder instruction nor the post-cast/decline tail is asserted. Suggested fix: make the runtime case drive the entire spell and verify the three final zones above. Also use Portent's actual {X}{U} cost in the scenario rather than {X}{U}{U}.

🟡 Scope

[LOW] PR_6498.md duplicates the PR narrative as a new repository file. Evidence: PR_6498.md. Suggested fix: remove it; the issue/PR description is the appropriate home for this context.

Recommendation

Please address the stable-set and end-to-end coverage blockers on a new head, then request re-review. CI and CodeRabbit are still running, so they cannot validate the missing tail behavior yet.

@matthewevans matthewevans added the bug Bug fix label Jul 23, 2026
@matthewevans matthewevans removed their assignment Jul 23, 2026
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 19 card(s), 26 signature(s) (baseline: main cb7d5a25fa51)

🟢 Added (14 signatures)

  • 4 cards · ➕ ability/Dig · added: Dig (count=Variable { name: "X" }, keep_count=0, reveal=true)
    • Affected (first 3): Enshrined Memories, Epiphany at the Drownyard, Green Sun's Twilight (+1 more)
  • 2 cards · ➕ ability/Dig · added: Dig (count=EventContextAmount, keep_count=0, reveal=true)
    • Affected (first 3): Glimpse of Tomorrow, Glint Raker
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (conditional=previous effect outcome, target=tracked set #0 matching shares creature type with reference creature, to=battlefield)
    • Affected (first 3): Call to the Kindred
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (enters_under=You, target=tracked set #0 matching creature, to=battlefield)
    • Affected (first 3): Lord of the Void
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (enters_under=You, target=tracked set #0 matching mv X- permanent non-land, to=battlefield)
    • Affected (first 3): Lonis, Cryptozoologist
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (from=exile, target=tracked set #0 matching creature + cards exiled by source, to=battlefield)
    • Affected (first 3): Anzrag's Rampage
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (target=tracked set #0 matching Elf or land, to=hand)
    • Affected (first 3): Elvish Elegy
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (target=tracked set #0 matching any target, to=hand)
    • Affected (first 3): Fathom Trawl
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (target=tracked set #0 matching any target, to=library)
    • Affected (first 3): Interplanar Tunnel
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (target=tracked set #0 matching land, to=battlefield)
    • Affected (first 3): Aminatou's Augury
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (target=tracked set #0 matching parent target, to=hand)
    • Affected (first 3): Explore the Vastlands
  • 1 card · ➕ ability/ChangeZoneAll · added: ChangeZoneAll (target=tracked set #0 matching tracked set #0, to=library)
    • Affected (first 3): Aethermage's Touch
  • 1 card · ➕ ability/Dig · added: Dig (count=BasicLandTypeCount { controller: You }, keep_count=0, reveal=true)
    • Affected (first 3): Sphinx of Clear Skies
  • 1 card · ➕ ability/Dig · added: Dig (count=FilteredTrackedSetSize { filter: Typed(TypedFilter { type_filters: [Land], controller: None, properties: [] }), caused_by: Some(Sacrificed) }, keep_…
    • Affected (first 3): Hew the Entwood

🔴 Removed (11 signatures)

  • 9 cards · ➖ ability/RevealTop · removed: RevealTop (count=1, player=controller)
    • Affected (first 3): Enshrined Memories, Epiphany at the Drownyard, Glimpse of Tomorrow (+6 more)
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (conditional=previous effect outcome, target=tracked set #0 matching shares creature type with reference creature, to=battlefield)
    • Affected (first 3): Call to the Kindred
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (enters_under=You, target=tracked set #0 matching creature, to=battlefield)
    • Affected (first 3): Lord of the Void
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (enters_under=You, target=tracked set #0 matching mv X- permanent non-land, to=battlefield)
    • Affected (first 3): Lonis, Cryptozoologist
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=tracked set #0 matching Elf or land, to=hand)
    • Affected (first 3): Elvish Elegy
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=tracked set #0 matching any target, to=hand)
    • Affected (first 3): Fathom Trawl
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=tracked set #0 matching any target, to=library)
    • Affected (first 3): Interplanar Tunnel
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=tracked set #0 matching creature + cards exiled by source, to=battlefield)
    • Affected (first 3): Anzrag's Rampage
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=tracked set #0 matching land, to=battlefield)
    • Affected (first 3): Aminatou's Augury
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=tracked set #0 matching parent target, to=hand)
    • Affected (first 3): Explore the Vastlands
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=tracked set #0 matching tracked set #0, to=library)
    • Affected (first 3): Aethermage's Touch
… 1 more signature(s) (1 card-changes) — showing first 1; see parse-diff.json

🟢 Added (1 signature)

  • 1 card · ➕ ability/Dig · added: Dig (count=ObjectManaValue { scope: EventSource }, keep_count=0, reveal=true)

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/engine/src/parser/oracle_effect/assembly.rs (1)

2305-2347: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve rest_destination for dynamic reveal Digs
crates/engine/src/parser/oracle_effect/assembly.rs:2305-2347 / crates/engine/src/game/effects/dig.rs

The dynamic-count branch clears rest_destination, but dig.rs returns early on keep_count == 0 before applying it. Any reveal-Dig that still needs a plain “put the rest …” move will silently drop that routing.

🤖 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/parser/oracle_effect/assembly.rs` around lines 2305 - 2347,
Preserve the original rest_destination when handling dynamic-count reveal Digs
in the assembly loop. In the non-Fixed branch, keep keep_count at zero and clear
only destination as needed, but do not clear rest_destination, so dig.rs can
apply the “put the rest” routing despite the keep_count == 0 early return.
🧹 Nitpick comments (1)
crates/engine/src/game/effects/dig.rs (1)

144-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate public-reveal marking logic vs. lines 186-200.

The is_reveal branch here (insert into revealed_cards, build card_names, push CardsRevealed) is byte-for-byte the same sequence already present later in this function for the non-zero keep_count path. Extract a small helper (e.g. mark_publicly_revealed(state, events, ability, &cards)) to avoid the two copies diverging later.

♻️ Suggested extraction
+fn mark_publicly_revealed(
+    state: &mut GameState,
+    events: &mut Vec<GameEvent>,
+    ability: &ResolvedAbility,
+    cards: &[CardId],
+) {
+    for &card_id in cards {
+        state.revealed_cards.insert(card_id);
+    }
+    let card_names: Vec<String> = cards
+        .iter()
+        .filter_map(|id| state.objects.get(id).map(|o| o.name.clone()))
+        .collect();
+    events.push(GameEvent::CardsRevealed {
+        player: ability.controller,
+        card_ids: cards.to_vec(),
+        card_names,
+    });
+}

Then call it from both the keep_count == 0 branch and the existing is_reveal block below.

🤖 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/effects/dig.rs` around lines 144 - 175, Extract the
duplicated public-reveal handling from the raw_keep_count == 0 branch and the
non-zero keep_count is_reveal path into a shared helper, such as
mark_publicly_revealed, that updates revealed_cards, builds card_names, and
emits CardsRevealed. Replace both inline sequences with calls to the helper
while preserving their existing controller and cards values.
🤖 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/parser/oracle_effect/sequence.rs`:
- Around line 6655-6683: Update the default branch of the destination selection
in the ForEachCategory ExileFromPool “put the rest” arm to use Zone::Library,
matching the sibling plain-Dig “put the rest” arm while preserving all explicit
phrase mappings.

In `@crates/engine/tests/integration/issue_6498_portent_of_calamity.rs`:
- Around line 107-177: Update
portent_exiled_picks_stay_exiled_not_dumped_to_graveyard so the revealed set
includes at least one additional card beyond the four selected type picks, such
as adding a fifth library card with a duplicate type or increasing X
accordingly. Track the unselected revealed card and assert it ends in
Zone::Graveyard, while retaining the existing assertions that selected cards
remain in Zone::Exile and outside the graveyard.

---

Outside diff comments:
In `@crates/engine/src/parser/oracle_effect/assembly.rs`:
- Around line 2305-2347: Preserve the original rest_destination when handling
dynamic-count reveal Digs in the assembly loop. In the non-Fixed branch, keep
keep_count at zero and clear only destination as needed, but do not clear
rest_destination, so dig.rs can apply the “put the rest” routing despite the
keep_count == 0 early return.

---

Nitpick comments:
In `@crates/engine/src/game/effects/dig.rs`:
- Around line 144-175: Extract the duplicated public-reveal handling from the
raw_keep_count == 0 branch and the non-zero keep_count is_reveal path into a
shared helper, such as mark_publicly_revealed, that updates revealed_cards,
builds card_names, and emits CardsRevealed. Replace both inline sequences with
calls to the helper while preserving their existing controller and cards values.
🪄 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: 106336a3-2947-4eae-83c1-299acedff565

📥 Commits

Reviewing files that changed from the base of the PR and between 135e483 and f8888c7.

📒 Files selected for processing (8)
  • PR_6498.md
  • crates/engine/src/game/effects/choose_from_zone.rs
  • crates/engine/src/game/effects/dig.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/parser/oracle_effect/assembly.rs
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/tests/integration/issue_6498_portent_of_calamity.rs
  • crates/engine/tests/integration/main.rs

Comment thread crates/engine/src/parser/oracle_effect/sequence.rs
Comment thread crates/engine/tests/integration/issue_6498_portent_of_calamity.rs Outdated
@matthewevans matthewevans self-assigned this Jul 23, 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.

Verdict: Blocked — the duplicate root narrative is removed on this head, but the selected-exile subset still has no stable binding through Portent’s final instruction.

🔴 Blocker

[HIGH] The two “rest” instructions require different, explicitly bound sets, but the new continuation matches any earlier per-category exile. Evidence: crates/engine/src/parser/oracle_effect/sequence.rs:3974-4010 searches all assembled effects for ForEachCategory and always emits Library + LastRevealed. That is correct only for the first “Put the rest into your graveyard”; Portent’s final “Then put the rest of the exiled cards into your hand” must operate on the category-exiled subset, excluding a free-cast card. The generic anaphor path also recognizes only “the rest” as TrackedSet at crates/engine/src/parser/oracle_target.rs:1030, so the “of the exiled cards” qualifier is lost. Why it matters: the final action can move no cards from Library after the first cleanup, or can draw from a chain-wide set rather than exactly the still-exiled picks. CR 608.2c requires the written instructions and their anaphora to be applied in order. Suggested fix: represent the revealed-library remainder and the category-exiled remainder as separate explicitly bound sets, then consume each at its corresponding instruction.

[HIGH] The dynamic-reveal demotion broadens behavior to nine cards without preserving the ordinary rest-disposition path. Evidence: crates/engine/src/parser/oracle_effect/assembly.rs:2310-2343 changes every non-fixed reveal Dig in this shape to keep_count 0 and clears rest_destination, while crates/engine/src/game/effects/dig.rs:149-181 returns before any rest move. The current parse-diff artifact records nine changed cards, including Sunbird’s Invocation and Sphinx of Clear Skies, but the new test covers only Portent. Why it matters: a dynamic reveal that still needs a non-category “put the rest” instruction now has no resolver path for it. Suggested fix: limit the Portent-specific remainder binding to its demonstrated grammar/IR class, or extend the general dynamic Dig representation and runtime resolution with card-class tests for every claimed changed signature.

[MED] The runtime test stops before either final remainder instruction and has no unselected revealed card. Evidence: crates/engine/tests/integration/issue_6498_portent_of_calamity.rs:119-177 reveals four distinct types, chooses all four, and exits at the free-cast prompt. Why it matters: it cannot fail if an unselected reveal is not put into the graveyard or if the remaining exiled cards are not put into hand. Suggested fix: reveal five cards with a duplicate type, complete both the decline/cast branch and final cleanup, and assert the library remainder is in the graveyard while only the remaining exile subset reaches hand. Use the card’s verified {X}{U} cost in that scenario.

✅ Clean

The focused removal of PR_6498.md leaves the issue and PR descriptions as the single narrative source.

Recommendation: request-changes — retain the cleanup, then rework the two remainder sets and add an end-to-end discriminating regression before re-review.

@matthewevans matthewevans removed their assignment Jul 23, 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.

Current-head maintainer review

The current head f0235059 only removes the duplicate root-level PR notes; the engine/parser changes remain otherwise identical to the previously blocked head.

[HIGH] The final Put the rest of the exiled cards into your hand still has no stable binding to the cards selected by the per-category exile. Evidence: choose_from_zone.rs:284 creates the fresh category set, choose_from_zone.rs:524 publishes its picks, sequence.rs:3975 inserts the revealed-card Library→Graveyard cleanup, and oracle_target.rs:1030 consumes only the the rest prefix as an unqualified tracked set. Why it matters: Portent must move only the uncast cards exiled by the earlier choices; it must not rely on the chain-wide set after the rejected revealed cards have also been processed. Suggested fix: model the final exiled subset explicitly, reusing the existing provenance-filtered tracked-set authority, and cover its decline/cast tail.

[HIGH] The runtime test still stops at the free-cast prompt and reveals exactly four different card types. Evidence: issue_6498_portent_of_calamity.rs:119-177. Why it matters: it neither proves a revealed-but-unselected card reaches the graveyard nor proves the final uncast exile cards reach hand. Suggested fix: reveal a fifth duplicate-type card, drive the optional cast choice to completion, and assert all three resulting zones.

The fresh parse-diff for the unchanged engine head affects nine dynamic-reveal cards. Its shared Dig { keep_count: 0 } path is plausible because it publishes CardsRevealed into the existing chain set, but the PR supplies no sibling runtime coverage; complete the audited class rather than treating Portent's partial scenario as proof.

CodeRabbit reconciliation: its missing-rest test finding is confirmed. Its rest_destination preservation suggestion is not sufficient: dig.rs returns early for keep_count == 0, so the correct route is the explicit chained disposition, not a retained unused field. The default-zone comment is a low-risk follow-up and does not resolve the blockers.

Requested before re-review: run the engine-implementer plan/review cycle for the typed final-exile binding, then provide discriminating full-pipeline tests for Portent and the affected dynamic-reveal siblings.

…e-rs#6498)

Separate revealed-library rest from exiled-card cleanup via LastRevealed and
TrackedSetFiltered(Exiled), emit explicit LastRevealed siblings for dynamic
reveal Digs, and lower the hand tail as ChangeZoneAll.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

Verdict: blocked — the Portent fix still ships an unproven parser-class expansion outside its stated scope.

🔴 Blocker

crates/engine/src/parser/oracle_effect/sequence.rs:4032 adds an explicit LastRevealed cleanup for every reveal-only Dig { keep_count: Some(0) }, while the current parse-diff reports nine affected cards: Enshrined Memories, Epiphany at the Drownyard, Glimpse of Tomorrow, Glint Raker, Green Sun's Twilight, Hew the Entwood, Sphinx of Clear Skies, Sunbird's Invocation, and Portent. The PR only has a Portent runtime fixture. This shared continuation branch can change where each sibling class sends its revealed remainder, but there is no class-level behavior map or discriminating coverage for those affected cards.

Suggested fix: either narrow the Portent-specific route to the proven ForEachCategory / exiled-card relation, or explicitly justify the generalized Dig semantics and add representative runtime tests for the affected count sources (X, event-context, tracked-set, and source-mana-value).

✅ Clean

crates/engine/src/parser/oracle_target.rs:1026 uses the existing typed TrackedSetFiltered { caused_by: Exiled } vocabulary for Portent’s final exile-set tail; the new Portent test exercises the optional-decline path.

Recommendation: request changes for the unexplained nine-card parser blast radius before approval.

@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

🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_target.rs (1)

1029-1046: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correctly disambiguates the two distinct "the rest" clauses on Portent of Calamity.

Verified against the card's actual Oracle text ("Put the rest into your graveyard. ... Then put the rest of the exiled cards into your hand.") — this arm targets only the second, more specific anaphor (exiled-minus-cast), and is correctly placed ahead of the generic TRACKED_SET_PHRASES table so the bare "the rest" tag doesn't prefix-match and strand " of the exiled cards" as an unconsumed remainder.

One nice-to-have: every other arm in this file has a dedicated unit test in the tests module; this new arm doesn't, relying solely on the separate integration test file for coverage.

🧪 Suggested unit test
#[test]
fn the_rest_of_the_exiled_cards_is_tracked_set_filtered_by_exile_cause() {
    let (f, rest) = parse_target("the rest of the exiled cards");
    assert_eq!(rest, "");
    assert_eq!(
        f,
        TargetFilter::TrackedSetFiltered {
            id: TrackedSetId(0),
            filter: Box::new(TargetFilter::Any),
            caused_by: Some(ThisWayCause::Exiled),
        }
    );
}
🤖 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/parser/oracle_target.rs` around lines 1029 - 1046, Add a
dedicated unit test in the parser’s existing tests module for the new “the rest
of the exiled cards” arm. Use parse_target, assert the remaining text is empty,
and verify the result equals TrackedSetFiltered with TrackedSetId(0),
TargetFilter::Any, and ThisWayCause::Exiled.
🤖 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/parser/oracle_effect/imperative.rs`:
- Around line 6530-6541: The mass-filter handling around
TargetFilter::TrackedSetFiltered currently defaults origin to Exile for every
cause; restrict that fallback to caused_by == Some(ThisWayCause::Exiled). Match
the ReturnAllToZone arm’s cause-sensitive behavior, preserving the existing
origin when the effect was caused by milling, discarding, returning,
sacrificing, destroying, or no specific cause.

---

Nitpick comments:
In `@crates/engine/src/parser/oracle_target.rs`:
- Around line 1029-1046: Add a dedicated unit test in the parser’s existing
tests module for the new “the rest of the exiled cards” arm. Use parse_target,
assert the remaining text is empty, and verify the result equals
TrackedSetFiltered with TrackedSetId(0), TargetFilter::Any, and
ThisWayCause::Exiled.
🪄 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: 84ae3fce-e563-47a4-acf1-deeaa29ad131

📥 Commits

Reviewing files that changed from the base of the PR and between f8888c7 and dfb940e.

📒 Files selected for processing (5)
  • crates/engine/src/game/effects/choose_from_zone.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/src/parser/oracle_target.rs
  • crates/engine/tests/integration/issue_6498_portent_of_calamity.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/engine/tests/integration/issue_6498_portent_of_calamity.rs

Comment thread crates/engine/src/parser/oracle_effect/imperative.rs
…se-rs#6498)

Fix clippy if-same-then-else, emit LastRevealed siblings for dynamic reveal
Digs before assembly demotion, and add parse coverage for the shared put-the-rest
grammar class Matthew requested.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

Request changes — current head 55fe7a3f.

The new helper broadens the parser from Portent to dynamic reveal-only Dig continuations, but the added coverage is AST-only. The prior blocker remains: this changes the shared class without representative runtime behavior proving the rest pile is moved correctly for a second affected card/class. Please add a discriminating runtime regression for a distinct dynamic reveal-only Dig with a trailing rest clause (and retain Portent), then re-request review.

@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/tests/integration/issue_6498_portent_of_calamity.rs`:
- Around line 164-208: Add a verified Comprehensive Rules citation annotation to
the test function dynamic_reveal_put_rest_emits_last_revealed_sibling, using the
required “CR <number>: <description>” format and covering reveal/remainder
instruction ordering and movement of the remainder to the specified zone. Keep
the test logic unchanged and ensure the cited rule numbers and descriptions are
verified.
- Around line 164-208: Extend
dynamic_reveal_put_rest_emits_last_revealed_sibling with a runtime scenario that
casts the parsed “Reveal the top X cards… Put the rest on the bottom… in any
order” effect using a concrete X value, resolves the library-ordering prompt,
and asserts that exactly the revealed cards are moved to the library bottom
while unrevealed cards remain in their original relevant positions. Reuse the
existing integration-test setup and resolution helpers, and retain the AST
assertion as a complementary check.
🪄 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: ff9b146f-e489-4189-bdb5-6a50d66b993e

📥 Commits

Reviewing files that changed from the base of the PR and between dfb940e and 55fe7a3.

📒 Files selected for processing (2)
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/tests/integration/issue_6498_portent_of_calamity.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/engine/src/parser/oracle_effect/sequence.rs

Comment thread crates/engine/tests/integration/issue_6498_portent_of_calamity.rs
andriy-polanski and others added 2 commits July 23, 2026 16:50
…-rs#6498)

Add a discriminating cast-pipeline test for the shared dynamic-reveal +
put-the-rest grammar class, stamp library-bottom placement on the explicit
LastRevealed sibling, and narrow TrackedSetFiltered mass-move origin defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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/issue_6498_portent_of_calamity.rs`:
- Around line 250-276: Update the test around runner.cast(spell).x(3).resolve()
to provide a player-selected, non-default ordering through the production
pipeline, exercising the ordering choice and failure path as required. Replace
the position-only assertions with an exact post-resolution library sequence that
verifies the unrevealed card remains on top and the revealed cards follow in the
selected order.
🪄 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: 852d937c-00ae-4fd8-9d08-abf3a7e4ffe7

📥 Commits

Reviewing files that changed from the base of the PR and between 55fe7a3 and 6273576.

📒 Files selected for processing (3)
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/tests/integration/issue_6498_portent_of_calamity.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/engine/src/parser/oracle_effect/sequence.rs

Comment thread crates/engine/tests/integration/issue_6498_portent_of_calamity.rs Outdated

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

Held on current head 8024cbb0. The new dynamic_reveal_put_rest_moves_revealed_cards_to_library_bottom scenario supplies the requested production-path sibling coverage beyond Portent. Required Rust verification remains in progress and Tauri currently reports a failed job, so no approval or queue action yet.

…sion

The runtime sibling test for reveal-only Dig + put-the-rest was failing CI
because it assumed reveal order rather than the engine's deterministic batch
placement order.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

Request changes — current head 02ecfff3.

The new regression hard-codes a deterministic bottom-tail order and explicitly asserts that resolution reaches Priority with no prompt. That is rules-incorrect for this Oracle text: "Put the rest on the bottom of your library in any order" moves three cards simultaneously, so their owner may arrange them in any order (CR 401.4). The parser currently records this continuation with reorder_all: false in oracle_effect/sequence.rs, and the test consequently proves the missing choice rather than the requested behavior.

Please preserve the existing selected-exile/remainder fix, but model this library-bottom ordering at the continuation/resolution seam and add a production-path regression that submits a non-default order and verifies it becomes the bottom tail. Do not approve or enqueue while the choice is absent.

…e-rs#6498)

CR 401.4: when multiple cards are placed at the same library position with
random_order false, route through EffectZoneChoice instead of a silent batch
order. Update the dynamic-reveal sibling regression to submit a non-default
bottom tail and verify it sticks.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

Blocked — the new order prompt gives the order to the wrong player.

🔴 Blocker

change_zone.rs:1669-1704 states the CR 401.4 owner-order rule, but creates one EffectZoneChoice with player: filter_controller for all matching cards. engine_resolution_choices.rs:6722-6733 then applies that one submitted order directly to each selected card’s owner library. A spell that returns/reorders multiple cards in a targeted opponent's library therefore lets the spell controller choose their order; if one mass effect covers multiple libraries, it also combines independent owner choices. CR 401.4 says the owner of those cards may arrange cards placed simultaneously in that library.

Split the matching cards by owner and surface one ordered choice per owner (or carry an owner-scoped continuation through the existing EffectZoneChoice path). Add a targeted-opponent or multi-owner runtime regression; the current self-library Portent scenario cannot exercise this authority boundary.

Recommendation: request changes before approval or enqueue.

The TrackedSetFiltered mass-move promotion must not absorb bounded "up to N"
picks like "put up to one land discarded this way onto the battlefield".

Co-authored-by: Cursor <cursoragent@cursor.com>

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

Blocked — the current head still gives a library-order choice to the wrong player.

🔴 Blocker

change_zone.rs:1669-1704 still creates one EffectZoneChoice for all matching cards with player: filter_controller. For cards put into the same library location in any order, CR 401.4 gives that ordering decision to each card’s owner. The matching set can include opponent-owned cards, so the current implementation lets the spell controller order them and collapses independent owner choices.

The new !up_to parser lowering is separate from this unresolved resolver defect and does not provide the needed targeted-opponent/multi-owner ordering regression.

Split the pending ordering work by owner (or otherwise prompt the proper owner for its cards), and add a runtime regression covering an opponent’s library.

Recommendation: request changes before approval or enqueue.

@matthewevans

Copy link
Copy Markdown
Member

Blocked on current head 855ee98 — the owner-vs-controller fix landed correctly and is proven at runtime, but the new multi-owner ordering is labeled APNAP and implemented as ascending seat id, and neither new test can tell the two apart.

The previous blocker (17:48, head 04c01b1) is genuinely resolved. Credit where it is due before the findings — see ✅ below.

🔴 Blocker

[HIGH] crates/engine/src/game/effects/change_zone.rs:1393-1407group_object_ids_by_owner_apnap documents CR 401.4 + APNAP ordering but returns owners in ascending PlayerId order:

/// CR 401.4: Group object ids by owner in APNAP order for per-owner library
/// arrangement prompts.
fn group_object_ids_by_owner_apnap(
    state: &GameState,
    object_ids: &[ObjectId],
) -> Vec<(PlayerId, Vec<ObjectId>)> {
    use std::collections::BTreeMap;
    let mut by_owner: BTreeMap<PlayerId, Vec<ObjectId>> = BTreeMap::new();
    ...
    by_owner.into_iter().collect()
}

BTreeMap<PlayerId, _> iterates by numeric seat id. It never reads state.active_player, never reads state.seat_order, and never reaches the team-turn branch. CR 101.4 (grep-verified, docs/MagicCompRules.txt:235) is explicit about the required order:

101.4. If multiple players would make choices and/or take actions at the same time, the active player (the player whose turn it is) makes any choices required, then the next player in turn order (usually the player seated to the active player's left) makes any choices required, followed by the remaining nonactive players in turn order.

Seat-id order equals APNAP order only when the active player is PlayerId(0). On the opponent's turn state.active_player == PlayerId(1), and a mass library placement spanning both players' cards prompts PlayerId(0) first — the nonactive player choosing before the active player. This is not an exotic corner; it is every other turn of a normal game. It is additionally wrong under a reversed seat_order (pinned by game/players.rs:472 apnap_order_reverses_with_turn_direction) and under shared-team-turn topology.

The engine already owns this. crates/engine/src/game/players.rs:203:

/// CR 101.4: APNAP (Active Player, Non-Active Player) ordering.
pub fn apnap_order(state: &GameState) -> Vec<PlayerId> {
    apnap_order_from(state, None, state.active_player)
}

and apnap_order_from handles the turn-direction anchor and delegates to topology::apnap_order_from for shared team turns. The directly analogous pending-queue you modeled this on already uses it — crates/engine/src/game/effects/mod.rs:7796-7802 builds PendingPlayerScopeSacrificeChoice::remaining_players by filtering the apnap sequence, and says so:

// Route through the ability-aware speed_effects::players_for_filter, then re-impose APNAP order by intersecting against the apnap sequence.

Suggested fix — same intersect-the-apnap-sequence shape, no new vocabulary:

let mut by_owner: HashMap<PlayerId, Vec<ObjectId>> = HashMap::new();
for &id in object_ids {
    by_owner.entry(state.objects[&id].owner).or_default().push(id);
}
crate::game::players::apnap_order(state)
    .into_iter()
    .filter_map(|pid| by_owner.remove(&pid).map(|cards| (pid, cards)))
    .collect()

[HIGH] change_zone.rs:5669+ and tests/integration/issue_6498_portent_of_calamity.rs — the APNAP assertion is vacuous and survives a revert to seat-id ordering. Every new test builds state with GameState::new_two_player(42), which is Self::new(FormatConfig::standard(), 2, seed). FormatConfig::starting_player (types/format.rs:576-580) returns PlayerId(0) for non-Archenemy formats, and seat_order is initialized (0..player_count).map(PlayerId) (types/game_state.rs:16023). So in every fixture here active_player == PlayerId(0) and seat_order == [P0, P1] — ascending seat id and APNAP coincide exactly.

That makes this assertion in change_zone_all_library_bottom_order_prompts_each_owner_sequentially non-discriminating:

assert_eq!(*player, PlayerId(0), "APNAP: active player's batch first");

It passes identically whether the production code is APNAP-correct or plain seat-ordered. A test that cannot fail on revert of the behavior it names is not coverage for that behavior.

Suggested fix: set state.active_player = PlayerId(1) in one multi-owner fixture and assert PlayerId(1)'s batch is prompted first. That single line fails today and passes after the apnap_order fix — which is exactly the discriminator this needs.

🟡 Non-blocking

[MED] crates/engine/src/game/scenario.rs:1500-1526 — the new GameRunner auto-answer matches any parked WaitingFor::EffectZoneChoice with !up_to, not just the library-order prompt this PR introduces, and silently submits the engine-listed order. Every existing scenario test that would previously have broken out of the advance loop on an unexpected EffectZoneChoice now auto-answers it instead. That converts a loud "an unexpected prompt appeared" signal into a silent pass across the whole suite. Scope the arm to effect_kind == EffectKind::PutAtLibraryPosition so unrelated zone choices keep breaking out.

[LOW] crates/engine/src/parser/oracle_target.rs:1029-1046 — the "the rest of the exiled cards" anaphor is hand-rolled as a standalone tag() block immediately above the existing TRACKED_SET_PHRASES table, which is the established home for exactly this vocabulary. Extending that table (or adding a cause-stamped sibling table beside it) keeps tracked-set anaphors discoverable in one place instead of splitting them across two mechanisms. The manual &text[lower.len() - rest.len()..] offset is correct here and is the sanctioned idiom for parse_target — no change needed there.

[LOW] Parse blast radius is real but under-covered at the edges. The <!-- coverage-parse-diff --> sticky reports 19 cards / 26 signatures: 9 cards flip RevealTop (count=1)Dig (keep_count=0, reveal=true) (Enshrined Memories, Epiphany at the Drownyard, Glimpse of Tomorrow, +6), and 10 flip ChangeZoneChangeZoneAll. Two of those — Interplanar Tunnel and Aethermage's Touch — are to=library, so they newly route into the mass library-order prompt branch. dynamic_reveal_put_rest_moves_revealed_cards_to_library_bottom covers the shape generically; a note in the PR body on why those two are safe would close the gap.

✅ Clean

  • The 17:48 blocker is fixed, and proven at the production seam. tests/integration/issue_6498_portent_of_calamity.rs:292-385 resolves a ChangeZoneAll controlled by P0 over three P1-owned revealed cards, asserts the prompt goes to P1 ("opponent-owned cards must be ordered by their owner, not the caster"), submits [rev3, rev1, rev2], and asserts the library bottom tail matches that exact order with deep still on top. That fails on revert of the owner routing. This is the runtime discrimination the last three rounds asked for.
  • The test file is registered. crates/engine/tests/integration/main.rs:580mod issue_6498_portent_of_calamity;. Not inert.
  • Zone authority (Gate B) respected. The new branch parks a WaitingFor and returns; delivery flows through the existing EffectZoneChoice + PutAtLibraryPosition production path. No new raw mover sequence — the only zones:: call on that path is the pre-existing // allow-raw-zone: in-library reposition, which is not a zone-change event.
  • State plumbing is complete, not partial. PendingMassLibraryOrderChoice threads all four required sites: #[serde(default, skip_serializing_if)] on the field, the _gamestate_partition_is_total destructure, the PartialEq arm, and the constructor init (types/game_state.rs:3591-3599, 12261-12264, 16230, 17564, 17764).
  • Every CR citation in this diff grep-verifies against docs/MagicCompRules.txt and describes the annotated code. Checked: 401.4 (:2000), 101.4 (:235), 701.20a, 701.20b, 701.20e, 608.2c, 607.2a, 107.3, 610.3, 122.1. CR 701.20b ("Revealing a card doesn't cause it to leave the zone it's in") is a genuinely apt citation for binding the rest pile to the still-in-library revealed cards.
  • The card premise is real. Scryfall Oracle text for Portent of Calamity, verbatim: Reveal the top X cards of your library. For each card type, you may exile a card of that type from among them. Put the rest into your graveyard. You may cast a spell from among the exiled cards without paying its mana cost if you exiled four or more cards this way. Then put the rest of the exiled cards into your hand. The two-remainder-set problem this PR describes is exactly what the card says. The cited sibling Sanar, Innovative First-Year is also real and does carry the ForEachCategory/ExileFromPool shape.
  • CodeRabbit's imperative.rs origin finding is addressed. The exile fallback is now cause-gated (imperative.rs:6533-6541), matching the ReturnAllToZone arm.

Refuting one external finding

CodeRabbit's sequence.rs:6738 comment states the new arm "defaults to Zone::Graveyard … while the near-identical plain-Dig arm defaults to Zone::Library." That does not hold on this head. The fallthrough is Zone::Library:

} else {
    // "on the bottom", "on top of", and other library rest piles.
    Zone::Library
};

Graveyard and Hand are both explicitly phrase-gated; Library is the default, consistent with the sibling arm. No change needed.

CI

Not contributor-caused. The only non-green check is Contributor trust (Superagent), a bot contributor flag rather than a code result, and it is not a required check. All four required jobs — Rust (fmt, clippy, test, coverage-gate), Frontend (lint, type-check, test), Card data, Rust tests shards — were still in progress at review time. mergeStateStatus is BEHIND, so the branch needs a refresh from main before the queue will take it regardless.


Recommendation: fix the two HIGH findings together — they are one change. Replace the BTreeMap grouping in group_object_ids_by_owner_apnap with a filter over players::apnap_order(state), then set state.active_player = PlayerId(1) in one multi-owner fixture and assert that PlayerId(1) is prompted first. The test change is what makes the production change provable: it fails on today's code and passes after the fix. Narrowing the scenario.rs auto-answer to EffectKind::PutAtLibraryPosition is a one-line follow-up worth folding into the same push. Everything else on this head — the owner routing, the runtime test that proves it, the state plumbing, and the CR annotations — is in good shape and does not need rework.

andriy-polanski and others added 3 commits July 23, 2026 21:55
…6545)

CR 101.4: group mass library-order batches via apnap_order instead of seat
id, make the multi-owner unit test active on P1's turn, and scope scenario
auto-answers to PutAtLibraryPosition only.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove unnecessary usize casts in scenario auto-answer path and bump
phase-tauri to 0.35.2 in client/src-tauri/Cargo.lock after the main merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
@matthewevans

Copy link
Copy Markdown
Member

Re-reviewed at 451908c4. The APNAP blocker is genuinely fixed, and you cleared the clippy failure before I could even report it — there is nothing left blocking this from my side.

✅ The APNAP fix is real, at the right seam

crates/engine/src/game/effects/change_zone.rs:1405 now filters the engine's own authority instead of iterating a BTreeMap<PlayerId, _> by seat id:

crate::game::players::apnap_order(state)
    .into_iter()
    .filter_map(|pid| by_owner.remove(&pid).map(|cards| (pid, cards)))

players::apnap_order anchors on state.active_player, walks seat_order under turn_direction (CR 103.1), delegates to topology::apnap_order_from for shared team turns, and skips non-alive players (CR 800.4f). CR 101.4 grep-verified at docs/MagicCompRules.txt:235. This is the same intersect-the-APNAP-sequence idiom as the PendingPlayerScopeSacrificeChoice precedent — no new vocabulary invented for it.

And the test now discriminates, which is what the last round was really asking for. Setting state.active_player = PlayerId(1) while the ability controller stays PlayerId(0), then asserting P1 is prompted first, fails both under the old seat-id ordering and under a controller-first implementation. That is a properly chosen discriminator, not a restatement of the code.

Also verified clean this round:

  • The scenario.rs auto-answer is correctly scoped — it breaks out on any effect_kind != EffectKind::PutAtLibraryPosition, so unrelated EffectZoneChoice prompts still fail loudly across the suite.
  • tests/fixtures/cr733/authority_matrix.json.gz is required, not dirt: 261 → 262 fields, the delta being exactly pending_mass_library_order_choice. That is the census gate doing its job on a new GameState field.
  • Parse blast radius is fully explained. All 19 cards decompose into your two stated root causes — 9 flip RevealTop(count=1)Dig(dynamic, keep_count=0, reveal=true), 10 flip ChangeZoneChangeZoneAll on tracked-set "put the rest". Both are corrections; nothing outside the intended class moved.
  • Portent of Calamity's Oracle text re-verified against Scryfall — matches what the PR quotes.

On the "the rest of the exiled cards" anaphor: I flagged last round that it sits outside TRACKED_SET_PHRASES. You answered it by documenting why — the CR 608.2c + CR 607.2a comment explaining that the bare tracked-set anaphor can absorb unrelated chain members after an intervening revealed-library cleanup. That is the better answer, and the finding is withdrawn.

🟡 One non-blocking item

change_zone.rs:1758 — now that group_object_ids_by_owner_apnap filters through apnap_order, an owner who is not alive has their batch dropped, so matching.len() > 1 no longer guarantees a non-empty result. .expect("matching.len() > 1 guarantees at least one owner batch") therefore claims an invariant its own guard no longer provides. Unreachable today (CR 800.4a removes a departed player's owned objects), so this is a message-accuracy fix rather than a bug — either reword it or fall back to the ungrouped batch if the filter empties.

📋 Housekeeping on the lockfile commit

451908c4 bumps client/src-tauri/Cargo.lock to phase-tauri 0.35.2. That was our breakage, not yours — the release: v0.35.2 commit bumped client/src-tauri/Cargo.toml without regenerating its lock, and Tauri compile check runs cargo check --locked. It has since been fixed on main in #6568, so your line is now identical to what main already carries and will merge without conflict. No action needed; thank you for unblocking yourself rather than waiting on us. (#6574 adds the lockfile to cargo-release's pre-release-replacements so it cannot recur.)


Recommendation: approve once CI reports green on 451908c4. The .expect() message is worth a follow-up line but is not worth another round-trip on its own — fold it in if you push again for any other reason. The APNAP correctness work, its discriminating test, the scoped scenario auto-answer, and the census fixture are all in good shape.

@matthewevans matthewevans mentioned this pull request Jul 23, 2026
@matthewevans matthewevans self-assigned this Jul 24, 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.

Approving on 451908c4. Re-verified live: all required checks green (Rust lint/tests shards, Card data, Frontend, Tauri, WASM), head unchanged since the 23:08Z re-review that cleared the APNAP blocker and its discriminating test. The only red is Contributor trust (Superagent bot flag, not a required check).

The .expect() message accuracy note on change_zone.rs:1758 remains a non-blocking follow-up — not worth another round-trip.

Enqueuing via merge-when-ready.

@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
@matthewevans matthewevans removed their assignment Jul 24, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Jul 24, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
Merged via the queue into phase-rs:main with commit 59d8250 Jul 24, 2026
13 of 14 checks passed
matthewevans added a commit to andriypolanski/phase that referenced this pull request Jul 24, 2026
Resolves the deterministic crates/engine/tests/integration/main.rs mod-line
conflict by keeping both test modules in rustfmt-sorted order. Maintainer-side
port: main advanced under this PR tonight (phase-rs#6538 phase-rs#6543 phase-rs#6545 phase-rs#6554 phase-rs#6563 phase-rs#6579).
The phase-tauri 0.35.1->0.35.2 client/src-tauri/Cargo.lock bump is now a no-op,
since main landed the identical bump in phase-rs#6568.
jsdevninja pushed a commit to jsdevninja/phase that referenced this pull request Jul 24, 2026
…6498) (phase-rs#6545)

* fix(engine): keep Portent exile picks out of the graveyard (phase-rs#6498)

Model put-the-rest after ForEachCategory as LastRevealed still in the library,
and preserve dynamic X on reveal Digs instead of collapsing to RevealTop{1}.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(PR-6545): remove duplicate PR narrative

* fix(engine): resolve Portent's two remainder sets and hand tail (phase-rs#6498)

Separate revealed-library rest from exiled-card cleanup via LastRevealed and
TrackedSetFiltered(Exiled), emit explicit LastRevealed siblings for dynamic
reveal Digs, and lower the hand tail as ChangeZoneAll.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(engine): unblock PR 6545 clippy and dynamic-reveal rest path (phase-rs#6498)

Fix clippy if-same-then-else, emit LastRevealed siblings for dynamic reveal
Digs before assembly demotion, and add parse coverage for the shared put-the-rest
grammar class Matthew requested.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(engine): add runtime coverage for dynamic reveal rest path (phase-rs#6498)

Add a discriminating cast-pipeline test for the shared dynamic-reveal +
put-the-rest grammar class, stamp library-bottom placement on the explicit
LastRevealed sibling, and narrow TrackedSetFiltered mass-move origin defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(test): assert exact library-bottom order in dynamic reveal regression

The runtime sibling test for reveal-only Dig + put-the-rest was failing CI
because it assumed reveal order rather than the engine's deterministic batch
placement order.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(engine): prompt library order for ChangeZoneAll bottom rest (phase-rs#6498)

CR 401.4: when multiple cards are placed at the same library position with
random_order false, route through EffectZoneChoice instead of a silent batch
order. Update the dynamic-reveal sibling regression to submit a non-default
bottom tail and verify it sticks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(parser): keep up-to tracked-set puts on ChangeZone (phase-rs#6498)

The TrackedSetFiltered mass-move promotion must not absorb bounded "up to N"
picks like "put up to one land discarded this way onto the battlefield".

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix

* fix

* fix

* fix(engine): use APNAP order for per-owner library prompts (phase-rs#6545)

CR 101.4: group mass library-order batches via apnap_order instead of seat
id, make the multi-owner unit test active on P1's turn, and scope scenario
auto-answers to PutAtLibraryPosition only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): unblock PR 6545 clippy and Tauri lock sync

Remove unnecessary usize casts in scenario auto-answer path and bump
phase-tauri to 0.35.2 in client/src-tauri/Cargo.lock after the main merge.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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

bug Bug fix contributor:flagged Contributor flagged for review by trust analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Portent of Calamity — revealed cards cannot be selected

3 participants