diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index eb0ef649a7..906798145c 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -795,6 +795,18 @@ export interface TokenCharacteristics { keywords: Keyword[]; } +/** + * Which keyword action put a permanent onto the battlefield face down + * (engine `FaceDownCause`). Only meaningful while `face_down` is true. + * `TurnedFaceDown` is the Ixidron class, for which no marker token is printed. + */ +export type FaceDownCause = + | "Manifest" + | "Morph" + | "Cloak" + | "Disguise" + | "TurnedFaceDown"; + export interface TokenImageRef { scryfall_id: string; scryfall_oracle_id?: string | null; @@ -1008,6 +1020,8 @@ export interface GameObject { display_visible_to_viewer?: boolean; tapped: boolean; face_down: boolean; + /** Set only while `face_down` is true; absent on older saves. */ + face_down_cause?: FaceDownCause | null; flipped: boolean; transformed: boolean; damage_marked: number; diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index 53459f2dd1..3b9f717270 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -319,6 +319,7 @@ function FanCard({ tokenImageRef={isToken ? obj.token_image_ref : undefined} oracleText={isToken ? obj.token_rules_text : undefined} faceDown={shouldRenderCardBack(obj)} + faceDownCause={obj.face_down ? obj.face_down_cause : undefined} className="!w-[var(--fan-card-w)] !h-[var(--fan-card-h)]" /> diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index 764011ad1c..64a9defa4a 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -913,7 +913,7 @@ export const PermanentCard = memo(function PermanentCard({ ) : ( <>
- + {/* CR 702.26: phased-out tint overlay — sky-blue mix-blend-screen matches the player-area treatment (PlayerArea.tsx 4d6cfb506). */} {isPhasedOut && ( @@ -1266,7 +1266,7 @@ const ExileGhostCard = memo(function ExileGhostCard({ objectId, offset }: ExileG {useArtCrop ? ( ) : ( - + )}
); diff --git a/client/src/components/card/ArtCropCard.tsx b/client/src/components/card/ArtCropCard.tsx index d4653e4bef..e631bca1ba 100644 --- a/client/src/components/card/ArtCropCard.tsx +++ b/client/src/components/card/ArtCropCard.tsx @@ -8,6 +8,7 @@ import { useIsMobile } from "../../hooks/useIsMobile.ts"; import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts"; import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts"; import { CARD_BACK_URL } from "../../services/scryfall.ts"; +import { faceDownMarkerRef } from "./faceDownMarker.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; import { COUNTER_COLORS, computePTDisplay, hasOtherPrintedFace, shouldRenderCardBack, toRoman } from "../../viewmodel/cardProps.ts"; @@ -43,12 +44,20 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr ? cardImageLookup(obj) : { name: "", faceIndex: 0, oracleId: undefined, faceName: undefined }; const isToken = obj?.display_source === "Token"; + // A face-down permanent shows the marker token for the ability that turned it + // face down (Morph / Manifest / A Mysterious Creature), the way paper play + // does. Without a marker the card back is rendered exactly as before. + const faceDownMarker = faceDownMarkerRef(obj?.face_down ?? false, obj?.face_down_cause); const { src: cardSrc, isLoading: cardLoading } = useCardImage(renderCardBack ? "" : imageLookup.name, { size: "art_crop", faceIndex: imageLookup.faceIndex, - isToken: renderCardBack ? false : isToken, + isToken: renderCardBack ? faceDownMarker !== null : isToken, tokenFilters: !renderCardBack && isToken && obj ? tokenFiltersForObject(obj) : undefined, - tokenImageRef: !renderCardBack && isToken && obj ? obj.token_image_ref : undefined, + tokenImageRef: renderCardBack + ? (faceDownMarker ?? undefined) + : isToken && obj + ? obj.token_image_ref + : undefined, oracleId: renderCardBack ? undefined : imageLookup.oracleId, faceName: renderCardBack ? undefined : imageLookup.faceName, }); @@ -74,7 +83,7 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr if (!obj) return null; - const src = renderCardBack ? CARD_BACK_URL : cardSrc; + const src = renderCardBack ? (cardSrc ?? CARD_BACK_URL) : cardSrc; const isLoading = renderCardBack ? false : cardLoading; // CR 712 vs CR 710: `back_face != null` is NOT "has a second face" — a // Kamigawa flip card stores its alternative half in the same slot and has no @@ -110,7 +119,13 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr ); } - const renderedSrc = renderCardBack ? CARD_BACK_URL : (src ?? ""); + // The card back is the fallback in BOTH directions: a marker that never + // resolves and a marker URL whose `` fails to load both land here. The + // artless text tile below is for face-UP cards with no printing; a face-down + // permanent always has the card back to fall back to. + const renderedSrc = renderCardBack + ? (artError ? CARD_BACK_URL : (src ?? CARD_BACK_URL)) + : (src ?? ""); const headerHeight = isCompactHeight ? "clamp(8px, calc(var(--art-crop-h) * 0.16), 12px)" : "clamp(8px, calc(var(--art-crop-h) * 0.18), 20px)"; @@ -185,7 +200,7 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr an artless permanent loses its picture but never its game state. `src` is non-null for face-down cards (CARD_BACK_URL), so those still render the card back here. */} - {src && !artError ? ( + {renderCardBack || (src && !artError) ? ( {cardName}` failed to load. // Both render the card/token name (and Oracle text when known) so every artless // card or token — not just one hard-coded name — stays identifiable. - const renderedSrc = faceDown ? CARD_BACK_URL : (src ?? ""); + // The card back is the fallback in BOTH directions: a marker that never + // resolves (`!src`) and a marker URL whose `` fails to load + // (`imageError` — offline, CDN gap, stale printing) both fall back to it. A + // face-down permanent must never render a broken image, and it must never + // fall through to the artless text tile either: `showArtFallback` stays gated + // on `!faceDown`, so this is the only fallback the face-down path has. + const renderedSrc = faceDown + ? (imageError ? CARD_BACK_URL : (src ?? CARD_BACK_URL)) + : (src ?? ""); const renderedAlt = faceDown ? t("card.faceDownName") : cardName; return ( diff --git a/client/src/components/card/__tests__/ArtCropCard.test.tsx b/client/src/components/card/__tests__/ArtCropCard.test.tsx index 14660a7b19..d28f31cd24 100644 --- a/client/src/components/card/__tests__/ArtCropCard.test.tsx +++ b/client/src/components/card/__tests__/ArtCropCard.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GameObject } from "../../../adapter/types.ts"; import { useCardImage } from "../../../hooks/useCardImage.ts"; +import { CARD_BACK_URL } from "../../../services/scryfall.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; import { ArtCropCard } from "../ArtCropCard.tsx"; @@ -278,6 +279,43 @@ describe("ArtCropCard", () => { expect(screen.getByAltText("Hidden Sorcery")).toBeInTheDocument(); }); + it("falls back to the card back when face-down marker art fails to load", () => { + // ArtCropCard is the default battlefield renderer. Keep its marker failure + // path covered separately from CardImage: the component owns its own + // artError state and must never leave a face-down permanent as a broken + // image when a marker printing is unavailable. + mockUseCardImage.mockReturnValue({ + src: "https://cards.scryfall.io/normal/front/m/a/manifest.jpg", + isLoading: false, + isRotated: false, + isFlip: false, + }); + const permanent = { + ...transformedPermanent(), + face_down: true, + face_down_cause: "Manifest" as const, + transformed: false, + back_face: null, + color: [], + base_color: [], + }; + useGameStore.setState({ + gameState: { objects: { [permanent.id]: permanent } } as never, + }); + + render(); + + const marker = screen.getByAltText("Face-down card"); + expect(marker).toHaveAttribute( + "src", + "https://cards.scryfall.io/normal/front/m/a/manifest.jpg", + ); + + fireEvent.error(marker); + + expect(screen.getByAltText("Face-down card")).toHaveAttribute("src", CARD_BACK_URL); + }); + it("keeps loyalty and P/T readable for planeswalkers and creature planeswalkers", () => { mockUseCardImage.mockReturnValue({ src: "card.png", diff --git a/client/src/components/card/__tests__/CardImage.test.tsx b/client/src/components/card/__tests__/CardImage.test.tsx index 686649087c..53884a1ee2 100644 --- a/client/src/components/card/__tests__/CardImage.test.tsx +++ b/client/src/components/card/__tests__/CardImage.test.tsx @@ -227,3 +227,54 @@ describe("CardImage art fallback (issue #6156)", () => { expect(img!.getAttribute("src")).toBe("https://example.invalid/back.png"); }); }); + +describe("CardImage face-down marker (#7532)", () => { + it("renders the marker token art for a face-down permanent", () => { + mockUseCardImage.mockReturnValue({ + src: "https://cards.scryfall.io/normal/front/m/a/manifest.jpg", + isLoading: false, + isRotated: false, + isFlip: false, + }); + + render(); + + const img = screen.getByRole("img"); + expect(img).toHaveAttribute( + "src", + "https://cards.scryfall.io/normal/front/m/a/manifest.jpg", + ); + }); + + it("falls back to the card back when the marker image fails to load", () => { + mockUseCardImage.mockReturnValue({ + src: "https://cards.scryfall.io/normal/front/m/a/manifest.jpg", + isLoading: false, + isRotated: false, + isFlip: false, + }); + + render(); + const img = screen.getByRole("img"); + // A resolved marker URL can still 404 (CDN gap, stale printing). A face-down + // permanent must never show a broken image, and must not fall through to the + // artless text tile either — the card back is its only fallback. + fireEvent.error(img); + + expect(screen.getByRole("img")).toHaveAttribute("src", CARD_BACK_URL); + }); + + it("keeps the card back when no marker applies", () => { + mockUseCardImage.mockReturnValue({ + src: null, + isLoading: false, + isRotated: false, + isFlip: false, + }); + + // `TurnedFaceDown` (Ixidron) has no printed marker token. + render(); + + expect(screen.getByRole("img")).toHaveAttribute("src", CARD_BACK_URL); + }); +}); diff --git a/client/src/components/card/__tests__/faceDownMarker.test.ts b/client/src/components/card/__tests__/faceDownMarker.test.ts new file mode 100644 index 0000000000..a88a9e3ef2 --- /dev/null +++ b/client/src/components/card/__tests__/faceDownMarker.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { faceDownMarkerRef } from "../faceDownMarker.ts"; + +describe("faceDownMarkerRef", () => { + it("maps each rules cause onto the printing paper play uses", () => { + expect(faceDownMarkerRef(true, "Manifest")?.face_name).toBe("manifest"); + expect(faceDownMarkerRef(true, "Morph")?.face_name).toBe("morph"); + // Cloak (CR 701.58a) and disguise (CR 702.168a) are different rules that + // share one printed token — the mapping is where they converge, not the + // engine's enum. + expect(faceDownMarkerRef(true, "Cloak")?.face_name).toBe("a mysterious creature"); + expect(faceDownMarkerRef(true, "Disguise")?.face_name).toBe("a mysterious creature"); + expect(faceDownMarkerRef(true, "Cloak")?.scryfall_oracle_id).toBe( + faceDownMarkerRef(true, "Disguise")?.scryfall_oracle_id, + ); + }); + + it("has no marker for a cause with no printed token", () => { + // Ixidron turns permanents face down with no keyword action, and Wizards + // prints nothing for it — the generic card back stays. + expect(faceDownMarkerRef(true, "TurnedFaceDown")).toBeNull(); + }); + + it("stays null unless the permanent is actually face down", () => { + // The engine leaves the cause on the object after it turns face up, so + // every reader must gate on `face_down`. This is that gate. + expect(faceDownMarkerRef(false, "Manifest")).toBeNull(); + expect(faceDownMarkerRef(true, null)).toBeNull(); + expect(faceDownMarkerRef(true, undefined)).toBeNull(); + }); +}); diff --git a/client/src/components/card/faceDownMarker.ts b/client/src/components/card/faceDownMarker.ts new file mode 100644 index 0000000000..3a793e703b --- /dev/null +++ b/client/src/components/card/faceDownMarker.ts @@ -0,0 +1,61 @@ +import type { FaceDownCause, TokenImageRef } from "../../adapter/types.ts"; + +/** + * The marker token Wizards prints for each face-down family. + * + * Paper play uses these as the required "what ability caused them to be face + * down" reminder (Duskmourn rulings, 2024-09-20), and the engine already tells + * us the cause. Mapping the cause onto a printing is a display decision, which + * is why the ids live here and not in the engine: four rules-level causes share + * three printed tokens, and one cause has no token at all. + * + * Oracle ids are used rather than a single printing's Scryfall id so the lookup + * survives a reprint — `fetchTokenImageByRef` falls back to the oracle key that + * `scryfall-token-images.json` already indexes for all three. + */ +const MARKERS: Partial> = { + // https://scryfall.com/card/tfrf/4/manifest — also used for manifest dread, + // which is the same keyword action with a different card-selection step. + Manifest: { + scryfall_id: "", + scryfall_oracle_id: "f4f184ef-f456-47d8-9012-095629a5ea4d", + face_name: "manifest", + preset_id: "face-down-manifest", + }, + // https://scryfall.com/card/tdtk/7/morph — megamorph shares it. + Morph: { + scryfall_id: "", + scryfall_oracle_id: "8f92f8d7-ec89-426f-86dc-fbc259eb5559", + face_name: "morph", + preset_id: "face-down-morph", + }, + // https://scryfall.com/card/tmkm/21/a-mysterious-creature — cloak and + // disguise are different rules (CR 701.58a vs CR 702.168a) with one printing. + Cloak: { + scryfall_id: "", + scryfall_oracle_id: "6481a124-6859-4f02-9fd3-b1302528dd2e", + face_name: "a mysterious creature", + preset_id: "face-down-cloak", + }, + Disguise: { + scryfall_id: "", + scryfall_oracle_id: "6481a124-6859-4f02-9fd3-b1302528dd2e", + face_name: "a mysterious creature", + preset_id: "face-down-cloak", + }, + // `TurnedFaceDown` (Ixidron class) is deliberately absent: no marker token is + // printed for it, so it keeps the generic card back. +}; + +/** + * The marker printing for a face-down permanent, or `null` when none applies — + * the permanent is face up, the engine did not record a cause (older saves), or + * the cause has no printed token. + */ +export function faceDownMarkerRef( + faceDown: boolean, + cause: FaceDownCause | null | undefined, +): TokenImageRef | null { + if (!faceDown || !cause) return null; + return MARKERS[cause] ?? null; +} diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 0e32716b87..398854b59b 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -10340,10 +10340,15 @@ fn face_down_cast_profile( state: &GameState, object_id: ObjectId, ) -> crate::types::ability::FaceDownProfile { + // CR 702.168a / CR 702.37a: a face-down CAST reuses the manifest/cloak + // characteristics but is a different keyword action, so it restates the + // cause instead of leaving the constructor's default in place. if super::keywords::object_has_effective_keyword_kind(state, object_id, KeywordKind::Disguise) { crate::types::ability::FaceDownProfile::cloaked_2_2() + .caused_by(crate::types::ability::FaceDownCause::Disguise) } else { crate::types::ability::FaceDownProfile::vanilla_2_2() + .caused_by(crate::types::ability::FaceDownCause::Morph) } } diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index e142524dcb..043adc49a3 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -8049,6 +8049,7 @@ mod tests { extra_core_types: vec![CoreType::Artifact], subtypes: vec!["Cyberman".to_string()], ward: None, + cause: crate::types::ability::FaceDownCause::Manifest, }), library_position: None, random_order: false, @@ -8738,6 +8739,7 @@ mod tests { extra_core_types: vec![CoreType::Land], subtypes: vec!["Forest".to_string()], ward: None, + cause: crate::types::ability::FaceDownCause::Manifest, }; let ability = ResolvedAbility::new( diff --git a/crates/engine/src/game/effects/manifest.rs b/crates/engine/src/game/effects/manifest.rs index 152bb5ac20..5be4e8fbee 100644 --- a/crates/engine/src/game/effects/manifest.rs +++ b/crates/engine/src/game/effects/manifest.rs @@ -411,6 +411,7 @@ mod tests { extra_core_types: vec![CoreType::Artifact], subtypes: vec!["Cyberman".to_string()], ward: None, + cause: crate::types::ability::FaceDownCause::Manifest, }; let ability = ResolvedAbility::new( Effect::Manifest { diff --git a/crates/engine/src/game/effects/turn_face_down.rs b/crates/engine/src/game/effects/turn_face_down.rs index bcb62b2c47..b0f9535f26 100644 --- a/crates/engine/src/game/effects/turn_face_down.rs +++ b/crates/engine/src/game/effects/turn_face_down.rs @@ -1,4 +1,6 @@ -use crate::types::ability::{Effect, EffectError, EffectKind, FaceDownProfile, ResolvedAbility}; +use crate::types::ability::{ + Effect, EffectError, EffectKind, FaceDownCause, FaceDownProfile, ResolvedAbility, +}; use crate::types::events::GameEvent; use crate::types::game_state::GameState; @@ -24,7 +26,16 @@ pub fn resolve( let (target, profile) = match &ability.effect { Effect::TurnFaceDown { target, profile } => ( target.clone(), - profile.clone().unwrap_or_else(FaceDownProfile::vanilla_2_2), + // CR 708.2: whatever characteristics the effect specifies, the + // ACTION here is a plain turn-face-down (Ixidron, Cyber Conversion) + // — no keyword action, and no marker token printed for it. The + // constructor default (manifest) and any authored profile both get + // the cause restated, so the profiled path cannot inherit a marker + // the rules never gave it. + profile + .clone() + .unwrap_or_else(FaceDownProfile::vanilla_2_2) + .caused_by(FaceDownCause::TurnedFaceDown), ), _ => return Ok(()), }; @@ -70,6 +81,11 @@ pub fn resolve( // CR 708.2a + CR 205.1a: Apply the effect-specified (or default vanilla // 2/2) face-down body. crate::game::morph::apply_face_down_creature_characteristics(obj, &profile); + // The public record of what turned this permanent face down. The zone + // authority (`zone_pipeline::apply_face_down_entry_profile`) stamps the + // same field for an ENTERING face-down permanent; this resolver turns a + // permanent already on the battlefield, so it stamps its own. + obj.face_down_cause = Some(profile.cause); obj.back_face = Some(snapshot); changed = true; events.push(GameEvent::TurnedFaceDown { object_id: id }); @@ -149,6 +165,7 @@ mod tests { extra_core_types: vec![CoreType::Artifact], subtypes: vec!["Cyberman".to_string()], ward: None, + cause: crate::types::ability::FaceDownCause::TurnedFaceDown, } } diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 68e0bdf38b..7e677046dd 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -430,6 +430,23 @@ pub struct GameObject { // Battlefield state pub tapped: bool, pub face_down: bool, + /// Which keyword action put this permanent face down (CR 701.40a manifest, + /// CR 702.37a morph, CR 701.58a cloak, CR 702.168a disguise). `None` for a + /// face-up permanent. + /// + /// CR 708.2a makes every face-down permanent look alike, so this is not a + /// characteristic — it is the public record of how the permanent got here, + /// which the 2024-09-20 Duskmourn rulings require play to keep visible. No + /// game rule reads it; it exists so the display layer can show the marker + /// the physical game uses. + /// + /// Only meaningful while `face_down` is true. It is stamped on every + /// face-down entry and deliberately NOT cleared when the permanent turns + /// face up — a dozen unrelated paths clear `face_down`, and requiring each + /// to remember a second field is how a stale marker would eventually ship. + /// Read it gated on `face_down`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub face_down_cause: Option, pub flipped: bool, pub transformed: bool, /// CR 701.27f: Number of successful transforms/conversions of this object. @@ -1284,6 +1301,7 @@ fn _gameobject_partition_is_total(o: &GameObject) { display_visible_to_viewer: _, tapped: _, face_down: _, + face_down_cause: _, flipped: _, transformed: _, transformation_count: _, @@ -2171,6 +2189,7 @@ impl GameObject { display_visible_to_viewer: false, tapped: false, face_down: false, + face_down_cause: None, flipped: false, transformed: false, transformation_count: 0, diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index e800c9c41e..dfa4f393ce 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -1495,6 +1495,7 @@ mod tests { extra_core_types: vec![CoreType::Artifact], subtypes: vec!["Cyberman".to_string()], ward: None, + cause: crate::types::ability::FaceDownCause::Manifest, }; { let obj = state.objects.get_mut(&id).unwrap(); diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index d08130e83d..05b4160e36 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -3186,6 +3186,11 @@ pub(crate) fn apply_face_down_entry_profile( // survive the entry guard (which runs before exit cleanup); this is the // authoritative final assertion that survives it. obj.face_down = true; + // The public record of WHICH keyword action put this permanent face + // down. Re-stamped on every face-down entry, and only meaningful while + // `face_down` is true — the many turn-face-up paths leave it alone + // rather than each having to remember to clear it. + obj.face_down_cause = Some(profile.cause); obj.back_face = Some(original); } } diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 01d75c5ff6..f4256d1b0b 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -6129,6 +6129,7 @@ pub(super) fn parse_theyre_face_down_profile(lower: &str) -> Option Option Option, + /// Which keyword action is putting the permanent face down. Rides the + /// profile because the profile is what survives a CR 616.1 entry pause — + /// the parked `ZoneMoveRequest` carries it, and the resume path applies it + /// through the same helper. + #[serde(default)] + pub cause: FaceDownCause, } /// `serde` skip helper: the creature body is the CR 708.2a default and need not @@ -11691,9 +11733,22 @@ impl FaceDownProfile { extra_core_types: vec![], subtypes: vec![], ward: None, + cause: FaceDownCause::Manifest, } } + /// Restate the keyword action responsible for this face-down entry. + /// + /// The two constructors carry the characteristics-defining default + /// (manifest for the vanilla profile, cloak for the warded one). A caster + /// that reuses those characteristics for a DIFFERENT action — morph reuses + /// the vanilla profile, disguise the warded one — says so here rather than + /// letting the reader infer the action from the ward. + pub fn caused_by(mut self, cause: FaceDownCause) -> Self { + self.cause = cause; + self + } + /// CR 701.58a: The cloak face-down characteristics — a vanilla 2/2 creature /// with ward {2}. Otherwise identical to [`Self::vanilla_2_2`]; the card can /// still be turned face up for its mana cost if it's a creature card. @@ -11702,6 +11757,7 @@ impl FaceDownProfile { ward: Some(crate::types::keywords::WardCost::Mana( crate::types::mana::ManaCost::generic(2), )), + cause: FaceDownCause::Cloak, ..Self::vanilla_2_2() } } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 176f9e361f..e7f975f92b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -30539,6 +30539,7 @@ mod tests { extra_core_types: vec![crate::types::card_type::CoreType::Land], subtypes: vec!["Forest".to_string()], ward: None, + cause: crate::types::ability::FaceDownCause::Manifest, }), library_placement: None, effect_kind: crate::types::ability::EffectKind::ChangeZone, @@ -31020,6 +31021,7 @@ mod tests { extra_core_types: vec![crate::types::card_type::CoreType::Land], subtypes: vec!["Forest".to_string()], ward: None, + cause: crate::types::ability::FaceDownCause::Manifest, }), enter_with_counters: vec![], conditional_enter_with_counters: vec![], diff --git a/crates/engine/tests/integration/face_down_cause_marker.rs b/crates/engine/tests/integration/face_down_cause_marker.rs new file mode 100644 index 0000000000..610479bbb6 --- /dev/null +++ b/crates/engine/tests/integration/face_down_cause_marker.rs @@ -0,0 +1,216 @@ +//! CR 708.2 + the Duskmourn rulings of 2024-09-20: a face-down permanent records +//! WHICH keyword action put it face down (#7532). +//! +//! > You must ensure that your face-down spells and permanents can be easily +//! > differentiated from each other. … The order in which they entered should +//! > remain clear, as well as what ability caused them to be face down. (This +//! > includes manifest, disguise, cloak, morph, and a few older effects that +//! > turn cards face down.) +//! +//! CR 708.2a gives every face-down permanent identical characteristics, so the +//! object itself cannot answer that question and the display layer had nothing +//! to show but a generic card back. No game rule reads the new field; it exists +//! so the client can show the marker token paper play uses. +//! +//! The cause rides `FaceDownProfile`, which is what survives a CR 616.1 entry +//! pause, and is stamped by the single face-down entry helper +//! (`zone_pipeline::apply_face_down_entry_profile`). + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::FaceDownCause; +use engine::types::game_state::WaitingFor; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +/// Resolve `oracle` as a 0-cost sorcery with `library` cards available, then +/// report the cause recorded on the one face-down permanent it produced. +fn cause_after( + oracle: &str, + library: usize, + answer_manifest_choice: bool, +) -> Option { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + for i in 0..library { + scenario.add_card_to_library_top(P0, &format!("Library {i}")); + } + let spell = scenario + .add_spell_to_hand(P0, "Face-Down Maker", false) + .from_oracle_text(oracle) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool(P0, vec![]); + let mut runner = scenario.build(); + + runner.cast(spell).resolve(); + runner.advance_until_stack_empty(); + if answer_manifest_choice { + if let WaitingFor::ManifestDreadChoice { cards, .. } = runner.state().waiting_for.clone() { + runner + .act(engine::types::actions::GameAction::SelectCards { + cards: vec![cards[0]], + }) + .expect("choose the card to manifest"); + runner.advance_until_stack_empty(); + } + } + + let face_down: Vec<_> = runner + .state() + .objects + .values() + .filter(|object| object.zone == Zone::Battlefield && object.face_down) + .collect(); + assert_eq!( + face_down.len(), + 1, + "reach guard: exactly one face-down permanent, got {face_down:?}" + ); + face_down[0].face_down_cause +} + +/// CR 701.62a: manifest dread records the manifest cause. +#[test] +fn manifest_dread_records_the_manifest_cause() { + assert_eq!( + cause_after("Manifest dread.", 2, true), + Some(FaceDownCause::Manifest) + ); +} + +/// CR 701.40a: plain manifest records the same cause — same keyword action, +/// different card-selection step, one marker token in paper. +#[test] +fn plain_manifest_records_the_manifest_cause() { + assert_eq!( + cause_after("Manifest the top card of your library.", 1, false), + Some(FaceDownCause::Manifest) + ); +} + +/// CR 701.58a: cloak is its own keyword action and gets its own marker, even +/// though its characteristics are manifest's plus ward {2}. Keying the display +/// on the ward instead would be classifying by shape rather than asking the +/// rules. +#[test] +fn cloak_records_the_cloak_cause() { + assert_eq!( + cause_after("Cloak the top card of your library.", 1, false), + Some(FaceDownCause::Cloak) + ); +} + +/// The counter-direction that keeps the field honest: a face-UP permanent +/// carries no cause, so a display layer that forgets to gate on `face_down` +/// still has nothing to show. +#[test] +fn a_face_up_permanent_records_no_cause() { + let mut scenario = GameScenario::new(); + let creature = scenario.add_creature(P0, "Plain Creature", 2, 2).id(); + let runner = scenario.build(); + assert!(!runner.state().objects[&creature].face_down); + assert_eq!(runner.state().objects[&creature].face_down_cause, None); +} + +/// CR 702.37a: a face-down CAST is morph, not manifest, even though it reuses +/// the same vanilla 2/2 characteristics. Keying the marker on those +/// characteristics would show the wrong token. +#[test] +fn a_morph_cast_records_the_morph_cause() { + assert_eq!( + cause_after_face_down_cast(engine::types::keywords::Keyword::Morph( + engine::types::mana::ManaCost::generic(5) + )), + Some(FaceDownCause::Morph) + ); +} + +/// CR 702.168a: disguise reuses cloak's warded characteristics and is still its +/// own action — the ward is shared, the marker source is not. +#[test] +fn a_disguise_cast_records_the_disguise_cause() { + assert_eq!( + cause_after_face_down_cast(engine::types::keywords::Keyword::Disguise( + engine::types::keywords::DisguiseCost::Mana(engine::types::mana::ManaCost::generic(5)) + )), + Some(FaceDownCause::Disguise) + ); +} + +/// Cast a creature carrying `keyword` face down for its fixed {3} and report the +/// cause recorded on the resulting permanent. +fn cause_after_face_down_cast(keyword: engine::types::keywords::Keyword) -> Option { + use engine::types::mana::{ManaType, ManaUnit}; + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let card = scenario + .add_creature_to_hand(P0, "Face-Down Cast Probe", 4, 4) + .with_mana_cost(ManaCost::NoCost) + .with_keyword(keyword) + .id(); + scenario.with_mana_pool( + P0, + (0..3) + .map(|_| ManaUnit::new(ManaType::Colorless, card, false, vec![])) + .collect(), + ); + let mut runner = scenario.build(); + runner.cast(card).commit().resolve(); + runner.advance_until_stack_empty(); + + let object = &runner.state().objects[&card]; + assert!( + object.face_down && object.zone == Zone::Battlefield, + "reach guard: the card must have entered the battlefield face down, got {object:?}" + ); + object.face_down_cause +} + +/// CR 708.2: an effect that turns a permanent already on the battlefield face +/// down is no keyword action at all — Wizards prints no marker for it, so the +/// cause must say so instead of inheriting the manifest default the vanilla +/// profile carries. +#[test] +fn a_generic_turn_face_down_records_its_own_cause() { + assert_eq!( + cause_after_turn_face_down("Turn target creature face down."), + Some(FaceDownCause::TurnedFaceDown) + ); +} + +/// The PROFILED variant (Cyber Conversion) takes the same path with an authored +/// body, and must not pick up a different cause because its profile is authored +/// rather than defaulted. +#[test] +fn a_profiled_turn_face_down_records_its_own_cause() { + assert_eq!( + cause_after_turn_face_down( + "Turn target creature face down. It's a 2/2 Cyberman artifact creature." + ), + Some(FaceDownCause::TurnedFaceDown) + ); +} + +fn cause_after_turn_face_down(oracle: &str) -> Option { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let victim = scenario.add_creature(P0, "Victim", 3, 3).id(); + let spell = scenario + .add_spell_to_hand(P0, "Turn-Down Probe", false) + .from_oracle_text(oracle) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool(P0, vec![]); + let mut runner = scenario.build(); + + runner.cast(spell).target_object(victim).resolve(); + runner.advance_until_stack_empty(); + + let object = &runner.state().objects[&victim]; + assert!( + object.face_down, + "reach guard: the creature must have been turned face down, got {object:?}" + ); + object.face_down_cause +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index e1744a5dc5..b522435a4c 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -231,6 +231,7 @@ mod exquisite_blood_routing; mod extract_power_each_player_exile; mod exuberant_wolfbear_base_pt_target; mod eyetwitch_learn_decline_lesson; +mod face_down_cause_marker; mod fact_or_fiction_pile_separation; mod fantastic_four_bounded_loop; mod fateful_handoff_target_mana_value_draw;