Skip to content

fix(engine): manifest dread drops a non-permanent card instead of manifesting it face down#4641

Open
luciferlive112116 wants to merge 7 commits into
phase-rs:mainfrom
luciferlive112116:fix-manifest-nonpermanent-facedown
Open

fix(engine): manifest dread drops a non-permanent card instead of manifesting it face down#4641
luciferlive112116 wants to merge 7 commits into
phase-rs:mainfrom
luciferlive112116:fix-manifest-nonpermanent-facedown

Conversation

@luciferlive112116

Copy link
Copy Markdown

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_change commits a battlefield entry by calling zones::move_to_zone (zone_pipeline.rs), and only applies the face-down profile (apply_face_down_entry_profile) afterward. But move_to_zone contains the CR 304.4 / CR 400.4a guard that rejects an instant/sorcery entering the battlefield unless obj.face_down is set (zones.rs). At the time of the move_to_zone call the object is not yet flagged face down, so the guard bounces a non-permanent manifest back to its origin zone.

  • A land manifests fine (it is a permanent type, so the guard doesn't apply).
  • A default/typeless scenario card manifests fine (not an instant/sorcery).
  • Only instant/sorcery cards hit the guard — matching the report.

Fix

In deliver_replaced_zone_change, flag the object face down just before the battlefield move dispatch when the delivered event carries a face_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 the face_down-clearing reset branches (which key on from == 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)

  • Produced via the /engine-implementer pipeline (plan → review-plan → implement → review-impl → commit)
  • Not /engine-implementer — explain why below

This is a minimal, narrowly-scoped ordering fix (flag face_down before the entry legality guard runs) plus a regression test, with no AST changes and no new effect/parser logic. Happy to route it through /engine-implementer if you'd prefer the full pipeline for an engine-behavior change.

CR references

  • CR 708.2a — a face-down permanent is a 2/2 creature with no name/types/abilities
  • CR 708.3 — turned face down before it enters the battlefield
  • CR 304.4 / CR 400.4a — instants/sorceries can't be put onto the battlefield (the guard being bypassed for face-down entries)

Verification

  • Added regression test crates/engine/tests/integration/issue_4544_abhorrent_oculus_manifest_nonpermanent.rs (registered in tests/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 on main (the card stays in the library) and passes with this fix.
  • The fix is backed by the existing zones.rs invariant tests face_down_instant_can_enter_battlefield and face_down_spell_stays_face_down_when_resolving_to_battlefield, which already prove that an object flagged face down before move_to_zone enters the battlefield legally and stays face down.
  • Suggested local check: cargo test -p engine --lib --test integration -- manifest face_down (covers issue_3245, issue_3311, and the new issue_4544 regression test). Please confirm via CI as part of review.

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

Comment thread crates/engine/src/game/zone_pipeline.rs Outdated
Comment on lines +1641 to +1645
if to == Zone::Battlefield && face_down_profile.is_some() {
if let Some(obj) = state.objects.get_mut(&object_id) {
obj.face_down = true;
}
}

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.

high

[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 matthewevans self-assigned this Jun 30, 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.

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

@matthewevans matthewevans removed their assignment Jun 30, 2026
luciferlive112116 and others added 4 commits June 30, 2026 18:40
…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 matthewevans self-assigned this Jun 30, 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.

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

@matthewevans matthewevans removed their assignment Jun 30, 2026
…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.
@luciferlive112116

Copy link
Copy Markdown
Author

Thanks for the review — addressed the HIGH rejected-entry issue.

The pre-move face-down flag is no longer committed unconditionally:

  1. Snapshot + rollback. deliver_replaced_zone_change now snapshots the prior face_down before the preflight, computes entered_battlefield (whether the object actually ended up on the battlefield after move_to_zone), and restores the prior value when the entry was rejected. So a CantEnterBattlefieldFrom static such as Grafdigger's Cage (CR 614.1d) no longer strands the card face down in its origin zone.
  2. Profile gated on actual entry. apply_face_down_entry_profile is now gated on entered_battlefield rather than the requested destination (to == Battlefield), so a rejected entry never morphs the hidden card in place — it is left fully unchanged.
  3. Regression added. blocked_battlefield_entry_does_not_strand_card_face_down: a face-down library→battlefield manifest blocked by Grafdigger's Cage stays in the library with face_down == false and no profile applied (back_face is None).

The successful path is unchanged: on a real entry the flag is re-asserted by apply_face_down_entry_profile, so the rollback is inert there.

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

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

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

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

2 participants