Skip to content

fix(engine): skip impossible targeted return after sacrificed token ceases to exist#4628

Open
jonathanchang31 wants to merge 4 commits into
phase-rs:mainfrom
jonathanchang31:fix-4571-it-that-betrays-token-return
Open

fix(engine): skip impossible targeted return after sacrificed token ceases to exist#4628
jonathanchang31 wants to merge 4 commits into
phase-rs:mainfrom
jonathanchang31:fix-4571-it-that-betrays-token-return

Conversation

@jonathanchang31

Copy link
Copy Markdown
Contributor

Summary

  • skip impossible targeted ChangeZone moves when the referenced object no longer exists
  • add a regression for It That Betrays + All Is Dust when the sacrificed set includes a token
  • close the object exists panic path from issue #4571

Change Type

  • Bug fix
  • New feature
  • Breaking change
  • Tests
  • Refactor

Behavior Proof

  • process_one_zone_move now returns Done immediately if the snapshotted ObjectId is no longer present in state.objects
  • this preserves the existing CR 400.7 / CR 111.7 behavior: once the object is gone, the zone move is impossible and should no-op rather than panic
  • the regression constructs the It That Betrays reanimation shape with a sacrificed colored token and asserts only the nontoken permanent returns

Validation

  • cargo fmt --all
  • targeted regression is currently queued behind an unrelated active workspace build in this environment:
    • cargo test -p engine all_is_dust_with_token_sacrifice_does_not_crash_it_that_betrays_return -- --nocapture

Closes #4571.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request addresses issue #4571 by ensuring that zone moves for objects that no longer exist (such as tokens removed by state-based actions) resolve as a no-op instead of causing a panic. A regression test simulating 'It That Betrays' and 'All Is Dust' with a token sacrifice has been added to verify this behavior. The feedback suggests optimizing the zone move check by retrieving the object once using state.objects.get(&obj_id) to avoid redundant map lookups in a hot path, which also simplifies the subsequent emblem check.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 781 to 793
// CR 111.7 + CR 400.7: a targeted return effect may still carry the
// snapshotted ObjectId of a token (or any other object that no longer
// exists) after state-based actions removed it from the game. Once the
// object is absent from `state.objects`, the zone move is impossible and
// must resolve as a no-op rather than calling `move_to_zone` on a missing
// object (issue #4571).
if !state.objects.contains_key(&obj_id) {
return ZoneMoveResult::Done;
}

// CR 114.5: Emblems cannot be moved between zones.
if state.objects.get(&obj_id).is_some_and(|o| o.is_emblem) {
return ZoneMoveResult::Done;

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.

medium

[MEDIUM] Redundant map lookups on state.objects.

Why it matters: Performing contains_key followed by get results in multiple redundant lookups on state.objects in a hot path.

Suggested fix: Retrieve the object once using state.objects.get(&obj_id) and bind it, which also simplifies the subsequent emblem check.

Suggested change
// CR 111.7 + CR 400.7: a targeted return effect may still carry the
// snapshotted ObjectId of a token (or any other object that no longer
// exists) after state-based actions removed it from the game. Once the
// object is absent from `state.objects`, the zone move is impossible and
// must resolve as a no-op rather than calling `move_to_zone` on a missing
// object (issue #4571).
if !state.objects.contains_key(&obj_id) {
return ZoneMoveResult::Done;
}
// CR 114.5: Emblems cannot be moved between zones.
if state.objects.get(&obj_id).is_some_and(|o| o.is_emblem) {
return ZoneMoveResult::Done;
// CR 111.7 + CR 400.7: a targeted return effect may still carry the
// snapshotted ObjectId of a token (or any other object that no longer
// exists) after state-based actions removed it from the game. Once the
// object is absent from state.objects, the zone move is impossible and
// must resolve as a no-op rather than calling move_to_zone on a missing
// object (issue #4571).
let Some(obj) = state.objects.get(&obj_id) else {
return ZoneMoveResult::Done;
};
// CR 114.5: Emblems cannot be moved between zones.
if obj.is_emblem {
return ZoneMoveResult::Done;
}
References
  1. Idiomatic Rust uses the type system, ownership model, and standard library idioms to their fullest, including avoiding redundant map lookups by leveraging Option-returning methods like get. (link)

@jonathanchang31

Copy link
Copy Markdown
Contributor Author

Addressed the redundant state.objects lookup review on this PR in commit 1932ad1a4. process_one_zone_move now binds state.objects.get(&obj_id) once, uses that bound object for the emblem check, and reuses obj.zone for the origin read.

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

[HIGH] The PR's own regression test fails, so the claimed production behavior is not fixed. Evidence: crates/engine/tests/issue_4571_it_that_betrays_all_is_dust.rs:87 drives the real cast pipeline, then crates/engine/tests/issue_4571_it_that_betrays_all_is_dust.rs:89 asserts the sacrificed nontoken permanent returned; GitHub Actions shard 1 fails at that assertion on this head. Why it matters: the crash guard may avoid one missing-object panic path, but the It That Betrays + All Is Dust scenario still does not satisfy the rule behavior the PR claims to cover. Suggested fix: fix the trigger/continuation path so the nontoken permanent returns under P0 while the sacrificed token remains gone, then keep this regression test green.

# Conflicts:
#	crates/engine/src/game/effects/change_zone.rs
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 32 card(s), 16 signature(s) (baseline: main 24bb0317d7ea)

16 card(s) · ability/Choose · added: Choose (choice=color, duration=until end of turn, persist=yes)

Examples: Aven Liberator, Blessed Breath, Center Soul (+13 more)

15 card(s) · ability/Choose · added: Choose (choice=color, duration=until end of turn, kind=activated, persist=yes)

Examples: Alseid of Life's Bounty, Armored Guardian, Benevolent Bodyguard (+12 more)

10 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, target=you control creature)

Examples: Aven Liberator, Blessed Breath, Center Soul (+7 more)

6 card(s) · ability/grant Protection · removed: grant Protection (affects=self, duration=until end of turn, grants=grant Protection, kind=activated)

Examples: Cartel Aristocrat, Jareth, Leonine Titan, Knight of Dawn (+3 more)

4 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, kind=activated, target=you control creature)

Examples: Armored Guardian, Benevolent Bodyguard, Moonlit Strider (+1 more)

3 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, kind=activated, target=creature)

Examples: Floating Shield, Stormscape Master, Thornscape Master

1 card(s) · static/RestrictLibrarySearchToTop(opponents,4) · added: RestrictLibrarySearchToTop(opponents,4)

Examples: Aven Mindcensor

1 card(s) · ability/grant Protection · removed: grant Protection (affects=controller, duration=until end of turn, grants=grant Protection, target=controller)

Examples: Seht's Tiger

1 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, kind=activated, target=you control Merfolk)

Examples: Sygg, River Guide

1 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, kind=activated, target=you control creature or you control enchan…

Examples: Alseid of Life's Bounty

1 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, target=creature)

Examples: Stave Off

1 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, target=parent target)

Examples: Feat of Resistance

1 card(s) · ability/grant Protection · removed: grant Protection (affects=parent target, duration=until end of turn, grants=grant Protection, target=you control permanent)

Examples: Faith's Shield

1 card(s) · ability/grant Protection · removed: grant Protection (affects=self, duration=until end of turn, grants=grant Protection)

Examples: Kami of the Painted Road

1 card(s) · ability/grant Protection · removed: grant Protection (affects=triggering source, duration=until end of turn, grants=grant Protection)

Examples: Pristine Skywise

1 card(s) · ability/replacement_structure · removed: replacement_structure

Examples: Aven Mindcensor

2 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

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

[HIGH] The PR's own regression still fails at the exact head. Evidence: crates/engine/tests/issue_4571_it_that_betrays_all_is_dust.rs:87 drives the cast pipeline and :89 asserts the nontoken permanent returned; GitHub check runs for f31b3938 show Rust tests (shard 1/2) failing. Why it matters: the missing-object no-op may avoid one panic, but the claimed It That Betrays / All Is Dust behavior is still not fixed. Suggested fix: fix the trigger/continuation path so the nontoken permanent returns under P0 while the sacrificed token remains gone, then keep this regression green.

Reviewed current head f31b3938060c5c89d841d99be6f7db880d1434e7.

@matthewevans matthewevans added the bug Bug fix label Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Engine crash: object exists

2 participants