fix(engine): manifest dread drops a non-permanent card instead of manifesting it face down#4641
Conversation
…ifesting it face down
There was a problem hiding this comment.
Code Review
This pull request addresses an issue where non-permanent cards (such as instants or sorceries) being manifested or put onto the battlefield face down were rejected by the battlefield-entry guard because the face_down flag was not set early enough. The fix sets obj.face_down = true prior to executing move_to_zone. An integration test has been added to verify this behavior. However, feedback points out a critical issue where face-down entries from Exile will still enter face up because apply_zone_exit_cleanup unconditionally clears the face_down flag; it is recommended to update apply_face_down_entry_profile to explicitly set obj.face_down = true to ensure the flag is restored after exit cleanup.
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.
| if to == Zone::Battlefield && face_down_profile.is_some() { | ||
| if let Some(obj) = state.objects.get_mut(&object_id) { | ||
| obj.face_down = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
[HIGH] Face-down entries from Exile will enter face up due to exit cleanup clearing the flag. Evidence: crates/engine/src/game/zone_pipeline.rs:1641. Why it matters: When moving from Exile to the Battlefield, apply_zone_exit_cleanup unconditionally clears face_down, and since the profile application does not re-assert it, the card enters face up. Suggested fix: Update apply_face_down_entry_profile to explicitly set obj.face_down = true so the flag is restored after exit cleanup.
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The pre-move face-down mutation is not rolled back when the battlefield entry is rejected. Evidence: crates/engine/src/game/zone_pipeline.rs:1641 sets obj.face_down = true before calling move_to_zone, but crates/engine/src/game/zones.rs:552 returns early when is_blocked_from_entering_battlefield matches a CantEnterBattlefieldFrom static such as Grafdigger's Cage (crates/engine/src/game/zones.rs:1189). Why it matters: a manifest/morph-style move that is blocked from entering the battlefield leaves the card in its original zone with face_down incorrectly set, corrupting hidden/card state even though the zone change never happened. Suggested fix: avoid committing the temporary guard flag unless the move succeeds, or snapshot and restore the prior face_down value when the object remains in its original zone; add a regression where a face-down library/graveyard-to-battlefield entry is blocked and the card stays unchanged.
…n test apply_face_down_entry_profile now explicitly re-asserts obj.face_down so a non-permanent put onto the battlefield face down from exile cannot leak face up: apply_zone_exit_cleanup clears the flag on every exile exit (CR 400.7) after the early pre-flag carried the card past the move_to_zone entry guard. Adds a focused unit test covering the Exile -> Battlefield face-down entry.
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The rejected-entry path still strands the object face down in its original zone. Evidence: crates/engine/src/game/zone_pipeline.rs:1652 mutates obj.face_down = true before zones::move_to_zone, but move_to_zone can return without moving at crates/engine/src/game/zones.rs:556 when is_blocked_from_entering_battlefield matches a CantEnterBattlefieldFrom static such as Grafdigger's Cage. The new apply_face_down_entry_profile assertion only runs after a successful move, so it does not repair this early-return path. Why it matters: a manifest/morph-style move blocked from entering the battlefield corrupts the card left in Library/Graveyard/etc. by marking it face down even though no zone change occurred. Suggested fix: snapshot and restore the previous face_down value when the object remains in its original zone after move_to_zone, or avoid persistent mutation for the guard preflight; add a regression with a CantEnterBattlefieldFrom static blocking a face-down library/graveyard-to-battlefield entry and asserting the card remains unchanged.
…blocked Addresses review on phase-rs#4641 (matthewevans, HIGH): the face-down preflight flag in deliver_replaced_zone_change was not rolled back when move_to_zone rejects the entry via a CantEnterBattlefieldFrom static (e.g. Grafdigger's Cage, CR 614.1d). A blocked manifest/morph entry left the card stranded face down in its origin zone and even applied the face-down profile there. - Snapshot the prior face_down and restore it when the object did not actually enter the battlefield. - Gate apply_face_down_entry_profile on entered_battlefield (actual entry) rather than the requested destination, so a rejected entry leaves the hidden card fully unchanged. - Add regression: a face-down library->battlefield manifest blocked by Grafdigger's Cage stays in the library with face_down=false and no profile applied.
|
Thanks for the review — addressed the HIGH rejected-entry issue. The pre-move face-down flag is no longer committed unconditionally:
The successful path is unchanged: on a real entry the flag is re-asserted by |
matthewevans
left a comment
There was a problem hiding this comment.
[LOW] New inline imports remain inside the added regression test. Evidence: crates/engine/src/game/zone_pipeline.rs:3015 imports FilterProp, StaticDefinition, TargetFilter, TypeFilter, and TypedFilter inside blocked_battlefield_entry_does_not_strand_card_face_down, and zone_pipeline.rs:3018 imports StaticMode inside the same function. Why it matters: the repo instructions require imports at file/module scope, and we have been applying this consistently on current PRs before approval. Suggested fix: move these imports to the surrounding test module's import block and leave the test body import-free.
The rejected-entry rollback shape now addresses my earlier blocker; this import cleanup is the remaining issue before this can be approved/updated/enqueued.
# Conflicts: # crates/engine/tests/integration/main.rs
matthewevans
left a comment
There was a problem hiding this comment.
[LOW] New inline imports remain inside the added regression test. Evidence: crates/engine/src/game/zone_pipeline.rs:3019. Why it matters: repo instructions explicitly require imports at file/module scope. Suggested fix: move FilterProp, StaticDefinition, TargetFilter, TypeFilter, TypedFilter, and StaticMode into the surrounding test module import block.
Reviewed current head edb76b251833731e77667e602658aa46d135e8c8.
Summary
Fixes manifest (manifest dread / morph-class face-down entries) dropping a chosen non-permanent (instant/sorcery) card: instead of manifesting it face down as a 2/2, the card was left in its origin zone. Addresses the engine half of #4544 (Abhorrent Oculus manifesting non-permanent cards "disappear"). A manifested card is a colorless 2/2 creature regardless of its hidden card types (CR 708.2a), so an instant/sorcery must manifest like any other card.
Root cause
zone_pipeline::deliver_replaced_zone_changecommits a battlefield entry by callingzones::move_to_zone(zone_pipeline.rs), and only applies the face-down profile (apply_face_down_entry_profile) afterward. Butmove_to_zonecontains the CR 304.4 / CR 400.4a guard that rejects an instant/sorcery entering the battlefield unlessobj.face_downis set (zones.rs). At the time of themove_to_zonecall the object is not yet flagged face down, so the guard bounces a non-permanent manifest back to its origin zone.Fix
In
deliver_replaced_zone_change, flag the object face down just before the battlefield move dispatch when the delivered event carries aface_down_profile, so the legality guard sees the incoming face-down entry. The full face-down profile is still applied right after the move. A Library/Hand → Battlefield manifest never hits theface_down-clearing reset branches (which key onfrom== Exile/Battlefield/Stack), so the flag survives until the profile is applied.This mirrors the already-tested invariants in
zones.rs(face_down_instant_can_enter_battlefield,face_down_spell_stays_face_down_when_resolving_to_battlefield).Implementation method (required)
/engine-implementerpipeline (plan → review-plan → implement → review-impl → commit)/engine-implementer— explain why belowCR references
Verification
crates/engine/tests/integration/issue_4544_abhorrent_oculus_manifest_nonpermanent.rs(registered intests/integration/main.rs): Abhorrent Oculus manifests a chosen instant face down as a 2/2 on the battlefield and graves the other looked-at card. It fails onmain(the card stays in the library) and passes with this fix.zones.rsinvariant testsface_down_instant_can_enter_battlefieldandface_down_spell_stays_face_down_when_resolving_to_battlefield, which already prove that an object flagged face down beforemove_to_zoneenters the battlefield legally and stays face down.cargo test -p engine --lib --test integration -- manifest face_down(coversissue_3245,issue_3311, and the newissue_4544regression test). Please confirm via CI as part of review.