From 83933ddb7dfa8ae554667724c541da5c59d3aaf8 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:48:37 +0200 Subject: [PATCH 1/3] feat(engine,client): show the marker token a face-down permanent was made with (#7532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 708.2a gives every face-down permanent identical characteristics, so a manifested 2/2, a morphed 2/2 and a cloaked 2/2 were indistinguishable on the board — all three rendered the same generic card back. Paper play does not allow that. The 2024-09-20 Duskmourn rulings require it: "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.)" which is why Wizards prints a marker token for each family. Engine: `FaceDownCause` records which keyword action put the permanent face down — CR 701.40a/701.62a manifest, CR 702.36a morph, CR 701.58a cloak, CR 702.166a disguise, plus `TurnedFaceDown` for the Ixidron class that has no keyword action at all. The variants follow the RULES, not the markers: morph and megamorph are one ability with one token, while cloak and disguise are two different rules that share a token. Collapsing them would put a display decision in the engine. 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`). The two constructors carry the characteristics-defining default; the face-down CAST path restates its own cause via `caused_by` instead of leaving the reader to infer morph-vs-manifest from the ward. No game rule reads the field. It is deliberately NOT cleared on turning 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. Readers gate on `face_down`; both client call sites do, and `faceDownMarkerRef` enforces it. Client: `faceDownMarkerRef` maps the four causes onto three printings, all three of which are already in the shipped `scryfall-token-images.json` index, so no data change is needed. `CardImage` and `ArtCropCard` feed the marker through the existing `tokenImageRef` path; `CARD_BACK_URL` stays the fallback for an unknown cause, the marker-less `TurnedFaceDown` cause, and a lookup that fails. Not covered: the morph and disguise causes have no runtime test. Both are set by `face_down_cast_profile` on the face-down CAST path, which needs a card with the keyword and a {3} payment to exercise; the manifest and cloak causes are covered end to end, and the cast path's two lines are a straight-line restatement of the same enum. Co-Authored-By: Claude Opus 5 --- client/src/adapter/types.ts | 14 +++ client/src/components/board/AttachmentFan.tsx | 1 + client/src/components/board/PermanentCard.tsx | 4 +- client/src/components/card/ArtCropCard.tsx | 19 ++- client/src/components/card/CardImage.tsx | 23 +++- .../card/__tests__/faceDownMarker.test.ts | 31 +++++ client/src/components/card/faceDownMarker.ts | 61 ++++++++++ crates/engine/src/game/casting.rs | 5 + crates/engine/src/game/effects/change_zone.rs | 2 + crates/engine/src/game/effects/manifest.rs | 1 + .../engine/src/game/effects/turn_face_down.rs | 1 + crates/engine/src/game/game_object.rs | 19 +++ crates/engine/src/game/morph.rs | 1 + crates/engine/src/game/zone_pipeline.rs | 5 + .../src/parser/oracle_effect/sequence.rs | 3 + crates/engine/src/types/ability.rs | 56 +++++++++ crates/engine/src/types/game_state.rs | 2 + .../integration/face_down_cause_marker.rs | 114 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 19 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 client/src/components/card/__tests__/faceDownMarker.test.ts create mode 100644 client/src/components/card/faceDownMarker.ts create mode 100644 crates/engine/tests/integration/face_down_cause_marker.rs 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..3ff0d82ae3 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,9 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr ); } - const renderedSrc = renderCardBack ? CARD_BACK_URL : (src ?? ""); + // The card back remains the fallback: a marker that fails to resolve must not + // leave the permanent blank. + const renderedSrc = renderCardBack ? (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)"; diff --git a/client/src/components/card/CardImage.tsx b/client/src/components/card/CardImage.tsx index 3825b71e96..91e616b2a1 100644 --- a/client/src/components/card/CardImage.tsx +++ b/client/src/components/card/CardImage.tsx @@ -3,8 +3,9 @@ import { useTranslation } from "react-i18next"; import { useCardImage } from "../../hooks/useCardImage.ts"; import { useEngineCardData } from "../../hooks/useEngineCardData.ts"; import type { TokenSearchFilters } from "../../services/scryfall.ts"; -import type { TokenImageRef } from "../../adapter/types.ts"; +import type { FaceDownCause, TokenImageRef } from "../../adapter/types.ts"; import { CARD_BACK_URL } from "../../services/scryfall.ts"; +import { faceDownMarkerRef } from "./faceDownMarker.ts"; import { getBevelBorderStyle } from "./cardFrame.ts"; import { getCardImageSrcSetProps } from "./cardImageSrcSet.ts"; import { CardArtFallback } from "./CardArtFallback.tsx"; @@ -23,6 +24,12 @@ interface CardImageProps { tokenFilters?: TokenSearchFilters; tokenImageRef?: TokenImageRef | null; faceDown?: boolean; + /** + * Which keyword action turned the permanent face down. Selects the marker + * token paper play uses (Morph / Manifest / A Mysterious Creature); without + * it — or for a cause with no printed marker — the generic card back stays. + */ + faceDownCause?: FaceDownCause | null; /** * Renders a {T} symbol overlay in the corner to mark a tapped battlefield * permanent. Used by selection modals — which display cards upright rather @@ -56,18 +63,24 @@ export function CardImage({ tokenFilters, tokenImageRef, faceDown = false, + faceDownCause, tapIndicator = false, oracleId, faceName, oracleText, }: CardImageProps) { const { t } = useTranslation("game"); + // A face-down permanent shows the marker token for the ability that turned it + // face down, the way paper play does. With no marker (unknown cause, or the + // Ixidron class, which has no printing) the lookup is skipped entirely and the + // generic card back is rendered exactly as before. + const faceDownMarker = faceDownMarkerRef(faceDown, faceDownCause); const { src, isLoading } = useCardImage(faceDown ? "" : cardName, { size, faceIndex, - isToken: faceDown ? false : isToken, + isToken: faceDown ? faceDownMarker !== null : isToken, tokenFilters: faceDown ? undefined : tokenFilters, - tokenImageRef: faceDown ? undefined : tokenImageRef, + tokenImageRef: faceDown ? (faceDownMarker ?? undefined) : tokenImageRef, oracleId: faceDown ? undefined : oracleId, faceName: faceDown ? undefined : faceName, }); @@ -113,7 +126,9 @@ export function CardImage({ // - `imageError`: the resolved `` 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 remains the fallback: a marker that fails to resolve (offline, + // missing printing) must not leave the permanent blank. + const renderedSrc = faceDown ? (src ?? CARD_BACK_URL) : (src ?? ""); const renderedAlt = faceDown ? t("card.faceDownName") : cardName; return ( 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..7721654468 --- /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.166a) 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..b6d852439d --- /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.166a) 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..3dd3ec4d92 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.166a / CR 702.36a: 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..02dff33a2c 100644 --- a/crates/engine/src/game/effects/turn_face_down.rs +++ b/crates/engine/src/game/effects/turn_face_down.rs @@ -149,6 +149,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..c0131103bb 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.36a morph, CR 701.58a cloak, CR 702.166a 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..2faedd30c2 --- /dev/null +++ b/crates/engine/tests/integration/face_down_cause_marker.rs @@ -0,0 +1,114 @@ +//! 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); +} 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; From c394326b16419199f0b448ba1cc66e1aff2702bd Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:54:09 +0200 Subject: [PATCH 2/3] fix(PR-7535): stamp the turn-down cause, fall back to the card back, fix the CR numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four review findings on `712a0074d`. 1. `effects::turn_face_down` never stamped a cause: it applies the profile directly rather than through the zone-entry authority, so a generic turn-down (Ixidron) and its profiled sibling (Cyber Conversion) both left the field `None` — or, on a re-turn, stale. The resolver now restates `FaceDownCause::TurnedFaceDown` on the resolved profile before applying it, so the authored-profile path cannot inherit the manifest default the vanilla constructor carries, and stamps the object itself. 2. A face-down marker URL that fails to load now falls back to `CARD_BACK_URL` in BOTH renderers. `CardImage` kept rendering the failed URL (its `showArtFallback` is gated on `!faceDown`); `ArtCropCard` swapped in the artless text tile, which is for face-UP cards with no printing. A face-down permanent has the card back to fall back to and must use it. 3. The morph and disguise CR numbers were wrong, and existed: CR 702.36 is Fear and CR 702.166 is Bargain. Corrected to CR 702.37a/702.37b (morph/megamorph) and CR 702.168a (disguise), verified against `docs/MagicCompRules.txt` lines 4288/4290/4292 and 5233/5235. 4. Coverage for the paths the metadata claims: * `a_morph_cast_records_the_morph_cause` and `a_disguise_cast_records_the_disguise_cause` drive a real face-down cast (keyword + the fixed {3}) through the scenario runner and reach-guard on the permanent having entered face down. * `a_generic_turn_face_down_records_its_own_cause` and `a_profiled_turn_face_down_records_its_own_cause` cover the default and the Cyber Conversion profile through the production spell path. * `CardImage.test.tsx` renders a resolved marker, dispatches an `error` on it and asserts the card back, and pins the marker-less `TurnedFaceDown` cause at the card back. Co-Authored-By: Claude Opus 5 --- client/src/components/card/ArtCropCard.tsx | 12 ++- client/src/components/card/CardImage.tsx | 12 ++- .../card/__tests__/CardImage.test.tsx | 51 +++++++++ .../card/__tests__/faceDownMarker.test.ts | 2 +- client/src/components/card/faceDownMarker.ts | 2 +- crates/engine/src/game/casting.rs | 2 +- .../engine/src/game/effects/turn_face_down.rs | 20 +++- crates/engine/src/game/game_object.rs | 2 +- crates/engine/src/types/ability.rs | 8 +- .../integration/face_down_cause_marker.rs | 102 ++++++++++++++++++ 10 files changed, 196 insertions(+), 17 deletions(-) diff --git a/client/src/components/card/ArtCropCard.tsx b/client/src/components/card/ArtCropCard.tsx index 3ff0d82ae3..e631bca1ba 100644 --- a/client/src/components/card/ArtCropCard.tsx +++ b/client/src/components/card/ArtCropCard.tsx @@ -119,9 +119,13 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr ); } - // The card back remains the fallback: a marker that fails to resolve must not - // leave the permanent blank. - const renderedSrc = renderCardBack ? (src ?? 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)"; @@ -196,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. - // The card back remains the fallback: a marker that fails to resolve (offline, - // missing printing) must not leave the permanent blank. - const renderedSrc = faceDown ? (src ?? 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__/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 index 7721654468..a88a9e3ef2 100644 --- a/client/src/components/card/__tests__/faceDownMarker.test.ts +++ b/client/src/components/card/__tests__/faceDownMarker.test.ts @@ -5,7 +5,7 @@ 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.166a) are different rules that + // 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"); diff --git a/client/src/components/card/faceDownMarker.ts b/client/src/components/card/faceDownMarker.ts index b6d852439d..3a793e703b 100644 --- a/client/src/components/card/faceDownMarker.ts +++ b/client/src/components/card/faceDownMarker.ts @@ -30,7 +30,7 @@ const MARKERS: Partial> = { 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.166a) with one printing. + // 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", diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 3dd3ec4d92..398854b59b 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -10340,7 +10340,7 @@ fn face_down_cast_profile( state: &GameState, object_id: ObjectId, ) -> crate::types::ability::FaceDownProfile { - // CR 702.166a / CR 702.36a: a face-down CAST reuses the manifest/cloak + // 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) { diff --git a/crates/engine/src/game/effects/turn_face_down.rs b/crates/engine/src/game/effects/turn_face_down.rs index 02dff33a2c..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 }); diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index c0131103bb..7e677046dd 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -431,7 +431,7 @@ pub struct GameObject { pub tapped: bool, pub face_down: bool, /// Which keyword action put this permanent face down (CR 701.40a manifest, - /// CR 702.36a morph, CR 701.58a cloak, CR 702.166a disguise). `None` for a + /// 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 diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 917192b4c1..184f425671 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -11643,8 +11643,8 @@ pub enum FaceDownBody { /// token for each family. /// /// The variants follow the RULES, not the markers: morph and megamorph share -/// one printed token but are one keyword ability (CR 702.36a/702.36b), while -/// cloak (CR 701.58a, a keyword action) and disguise (CR 702.166a, a cast +/// one printed token but are one keyword ability (CR 702.37a/702.37b), while +/// cloak (CR 701.58a, a keyword action) and disguise (CR 702.168a, a cast /// permission) share a token and are two different rules. Collapsing them here /// would put a display decision inside the engine; mapping the four causes onto /// three markers is the display layer's job. @@ -11655,11 +11655,11 @@ pub enum FaceDownCause { /// otherwise is manifesting it. #[default] Manifest, - /// CR 702.36a (morph) and CR 702.36b (megamorph) — cast face down for {3}. + /// CR 702.37a (morph) and CR 702.37b (megamorph) — cast face down for {3}. Morph, /// CR 701.58a (cloak) — like manifest, plus ward {2}. Cloak, - /// CR 702.166a (disguise) — cast face down for {3}, plus ward {2}. + /// CR 702.168a (disguise) — cast face down for {3}, plus ward {2}. Disguise, /// CR 708.2: an effect turned a permanent that was already on the /// battlefield face down (Ixidron class). No keyword action governs it and diff --git a/crates/engine/tests/integration/face_down_cause_marker.rs b/crates/engine/tests/integration/face_down_cause_marker.rs index 2faedd30c2..610479bbb6 100644 --- a/crates/engine/tests/integration/face_down_cause_marker.rs +++ b/crates/engine/tests/integration/face_down_cause_marker.rs @@ -112,3 +112,105 @@ fn a_face_up_permanent_records_no_cause() { 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 +} From 5cc4f9837ee140cd367393258ebbc56cfb548bc9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 11:13:21 -0700 Subject: [PATCH 3/3] test(PR-7535): cover art-crop marker fallback Co-authored-by: cuinhellcat --- .../card/__tests__/ArtCropCard.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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",