diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index 3b9f717270..d7140c2882 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -16,7 +16,6 @@ import { deriveActivationAffordances, resolveObjectActivation, } from "../../viewmodel/cardActionChoice.ts"; -import { shouldRenderCardBack } from "../../viewmodel/cardProps.ts"; import { CardImage } from "../card/CardImage.tsx"; import { fanGeometry, spreadFactor } from "../card/fanGeometry.ts"; @@ -318,7 +317,7 @@ function FanCard({ tokenFilters={isToken ? tokenFiltersForObject(obj) : undefined} tokenImageRef={isToken ? obj.token_image_ref : undefined} oracleText={isToken ? obj.token_rules_text : undefined} - faceDown={shouldRenderCardBack(obj)} + faceDown={obj.face_down === true} 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 64a9defa4a..b4d99a7d57 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -20,7 +20,7 @@ import { renderDescription } from "../../utils/description.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; import { buildGrantedKeywordSources, buildPTSources } from "../../viewmodel/attribution.ts"; -import { COUNTER_COLORS, computePTDisplay, counterIconClass, formatCounterType, shouldRenderCardBack, toRoman } from "../../viewmodel/cardProps.ts"; +import { COUNTER_COLORS, computePTDisplay, counterIconClass, formatCounterType, toRoman } from "../../viewmodel/cardProps.ts"; import { getCardDisplayColors } from "../card/cardFrame.ts"; import { ManaFontIcon } from "../icons/ManaFontIcon.tsx"; import { CounterTooltip } from "../ui/CounterTooltip.tsx"; @@ -502,7 +502,13 @@ export const PermanentCard = memo(function PermanentCard({ controllerIdentity || undefined, ); const { name: imgName, faceIndex: imgFace, oracleId: imgOracleId, faceName: imgFaceName } = cardImageLookup(obj); - const renderCardBack = shouldRenderCardBack(obj); + // The battlefield TILE of a face-down permanent always shows the cause + // marker / card back, exactly as the physical card lies in paper — for the + // controller too: the engine blanks a face-down permanent's live name and + // art (CR 708.2a), so there is no real face to draw here. The controller's + // peek lives in the hover preview, which resolves the stored face for + // `display_visible_to_viewer` objects (#7547). + const renderCardBack = obj.face_down === true; const hasSummoningSickness = obj.has_summoning_sickness ?? false; const ptDisplay = computePTDisplay(obj); diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index ef0eeae578..55ae02fb3b 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -2072,7 +2072,9 @@ describe("PermanentCard", () => { expect(getByLabelText("Face-down card")).toHaveAttribute("data-face-down", "true"); }); - it("renders a face-down permanent's identity when the engine projects it to this viewer", () => { + it("keeps the tile backed even when the engine projects the identity to this viewer (#7547)", () => { + // The controller's peek lives in the hover preview; the battlefield tile + // shows the cause marker exactly as the physical card lies face down. const gameState = makeState(); gameState.objects[1].face_down = true; gameState.objects[1].display_visible_to_viewer = true; @@ -2080,7 +2082,7 @@ describe("PermanentCard", () => { renderPermanent(); - expect(screen.getByLabelText("Test Creature")).toHaveAttribute("data-face-down", "false"); + expect(screen.getByLabelText("Face-down card")).toHaveAttribute("data-face-down", "true"); }); it("dispatches the engine-provided turn-face-up action", () => { diff --git a/client/src/components/card/ArtCropCard.tsx b/client/src/components/card/ArtCropCard.tsx index e631bca1ba..c2d4526113 100644 --- a/client/src/components/card/ArtCropCard.tsx +++ b/client/src/components/card/ArtCropCard.tsx @@ -8,10 +8,10 @@ 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 { faceDownMarkerName, 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"; +import { COUNTER_COLORS, computePTDisplay, hasOtherPrintedFace, toRoman } from "../../viewmodel/cardProps.ts"; import { CounterTooltip } from "../ui/CounterTooltip.tsx"; import { LoyaltyBadge } from "../ui/LoyaltyBadge.tsx"; import { CardArtFallback } from "./CardArtFallback.tsx"; @@ -38,8 +38,13 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr (s) => obj && s.gameState?.players?.find((p) => p.id === obj.controller)?.commander_color_identity, ); - const renderCardBack = shouldRenderCardBack(obj); - const cardName = renderCardBack ? t("card.faceDownName") : (obj?.name ?? ""); + // Same rule as `PermanentCard`: the tile always backs a face-down + // permanent (the live face is blanked per CR 708.2a); the controller's peek + // is the hover preview (#7547). + const renderCardBack = obj?.face_down === true; + const cardName = renderCardBack + ? (faceDownMarkerName(true, obj?.face_down_cause) ?? t("card.faceDownName")) + : (obj?.name ?? ""); const imageLookup = obj ? cardImageLookup(obj) : { name: "", faceIndex: 0, oracleId: undefined, faceName: undefined }; diff --git a/client/src/components/card/CardImage.tsx b/client/src/components/card/CardImage.tsx index 8846daec22..f07ad71bd5 100644 --- a/client/src/components/card/CardImage.tsx +++ b/client/src/components/card/CardImage.tsx @@ -5,7 +5,7 @@ import { useEngineCardData } from "../../hooks/useEngineCardData.ts"; import type { TokenSearchFilters } from "../../services/scryfall.ts"; import type { FaceDownCause, TokenImageRef } from "../../adapter/types.ts"; import { CARD_BACK_URL } from "../../services/scryfall.ts"; -import { faceDownMarkerRef } from "./faceDownMarker.ts"; +import { faceDownMarkerName, faceDownMarkerRef } from "./faceDownMarker.ts"; import { getBevelBorderStyle } from "./cardFrame.ts"; import { getCardImageSrcSetProps } from "./cardImageSrcSet.ts"; import { CardArtFallback } from "./CardArtFallback.tsx"; @@ -135,7 +135,9 @@ export function CardImage({ const renderedSrc = faceDown ? (imageError ? CARD_BACK_URL : (src ?? CARD_BACK_URL)) : (src ?? ""); - const renderedAlt = faceDown ? t("card.faceDownName") : cardName; + const renderedAlt = faceDown + ? (faceDownMarkerName(true, faceDownCause) ?? t("card.faceDownName")) + : cardName; return (
diff --git a/client/src/components/card/CardPreview.tsx b/client/src/components/card/CardPreview.tsx index 2287f83881..e18ffe84f5 100644 --- a/client/src/components/card/CardPreview.tsx +++ b/client/src/components/card/CardPreview.tsx @@ -16,6 +16,9 @@ import { useIsMobile } from "../../hooks/useIsMobile.ts"; import { useEngineCardData, useCardParseDetails, useCardRulings, type ParsedItem } from "../../hooks/useEngineCardData.ts"; import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts"; import { tokenFiltersForObject } from "../../services/cardImageLookup.ts"; +import { CARD_BACK_URL } from "../../services/scryfall.ts"; +import { faceDownMarkerRef } from "./faceDownMarker.ts"; +import { shouldRenderCardBack } from "../../viewmodel/cardProps.ts"; import type { CardRuling } from "../../services/engineRuntime.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; @@ -314,6 +317,28 @@ function CardPreviewInner({ const backParseDetails = useCardParseDetails(backFaceName); const isToken = obj?.display_source === "Token"; + // Face-down permanents (#7547): opponents preview the cause MARKER full + // size (it carries the mechanic's reminder text); the controller previews + // the real card alone — the marker would only cover its rules text, and the + // controller already knows the mechanic (playtest call, 2026-08-19). + const previewMarkerRef = faceDownMarkerRef( + obj?.face_down ?? false, + obj?.face_down_cause, + ); + const markerIsPrimary = + previewMarkerRef != null && obj != null && shouldRenderCardBack(obj); + // A hidden face-down PERMANENT whose cause has NO marker printing (unknown + // cause from an older save, or the Ixidron class) still gets a preview: the + // plain card back. It reveals nothing (CR 708.2a — the public face is a + // blank 2/2), and every art lookup below is suppressed so neither the + // generic label nor a blanked ref can leak into a network search. + // Battlefield only: a face-down card in a hidden zone (hideaway exile, + // issue #2889) has no public characteristics at all and keeps no preview. + const genericFaceDownBack = + obj != null + && obj.zone === "Battlefield" + && shouldRenderCardBack(obj) + && previewMarkerRef == null; // For transformed DFCs, the active face is the back (Scryfall faceIndex 1). // The engine swaps obj.name to the active face, but Scryfall always indexes // 0=front, 1=back regardless of search name — so we must flip the index. @@ -321,17 +346,27 @@ function CardPreviewInner({ const defaultFaceIndex = faceIndex ?? (isTransformed ? 1 : 0); // Battlefield path: route through oracle_id when the engine attached one. // Deck-builder path: `obj` is null, so we keep the name-based fallback. - const { src, isLoading, isRotated, isFlip } = useCardImage(cardName, { - size: "normal", - faceIndex: defaultFaceIndex, - isToken, - tokenFilters: isToken && obj ? tokenFiltersForObject(obj) : undefined, - tokenImageRef: isToken && obj ? obj.token_image_ref : undefined, - oracleId: obj?.printed_ref?.oracle_id, - faceName: obj?.printed_ref?.face_name, - scryfallId, - sourcePrinting, - }); + const suppressArtLookup = markerIsPrimary || genericFaceDownBack; + const { src, isLoading, isRotated, isFlip } = useCardImage( + genericFaceDownBack ? "" : cardName, + { + size: "normal", + faceIndex: defaultFaceIndex, + isToken: isToken || markerIsPrimary, + tokenFilters: isToken && obj && !genericFaceDownBack + ? tokenFiltersForObject(obj) + : undefined, + tokenImageRef: markerIsPrimary + ? previewMarkerRef + : isToken && obj && !genericFaceDownBack + ? obj.token_image_ref + : undefined, + oracleId: suppressArtLookup ? undefined : obj?.printed_ref?.oracle_id, + faceName: suppressArtLookup ? undefined : obj?.printed_ref?.face_name, + scryfallId, + sourcePrinting, + }, + ); const classLevel = obj?.class_level; const previewRef = useRef(null); const pointerRef = useRef<{ x: number; y: number } | null>(null); @@ -383,8 +418,16 @@ function CardPreviewInner({ faceName: showOtherFace ? otherFaceName : undefined, }); - const activeSrc = showOtherFace ? otherFaceImgResult.src : src; - const activeLoading = showOtherFace ? otherFaceImgResult.isLoading : isLoading; + const activeSrc = genericFaceDownBack + ? CARD_BACK_URL + : showOtherFace + ? otherFaceImgResult.src + : src; + const activeLoading = genericFaceDownBack + ? false + : showOtherFace + ? otherFaceImgResult.isLoading + : isLoading; const activeRotated = showOtherFace ? otherFaceImgResult.isRotated : isRotated; const displayName = showOtherFace ? backFaceName! : cardName; const showInfoPanel = obj?.zone === "Battlefield"; @@ -687,10 +730,8 @@ function CardPreviewInner({ @@ -792,35 +833,26 @@ function CardPreviewInner({ /** Mobile/tablet: card anchored right (landscape) or center (portrait), whole card visible. */ function MobilePreviewOverlay({ cardName, - faceIndex, - obj, + art, onDismiss, - sourcePrinting, layout = "modal", report, }: { cardName: string; backFaceName: string | null; - faceIndex?: number; - obj: GameObject | null; + /** The parent's RESOLVED art state (marker / generic back / peek already + * applied). The overlay must never run its own lookup: a second + * `useCardImage` with raw `printed_ref` fields is exactly the mobile + * hidden-information bypass the PR 7551 review flagged. */ + art: { src: string | null; isLoading: boolean; isRotated: boolean; isFlip: boolean }; onDismiss: () => void; - sourcePrinting?: SourcePrinting; layout?: "modal" | "compact"; /** In-game report context; absent in the deck builder. Only the full modal * layout hosts the button — the compact peek dismisses on any tap. */ report?: CardReportContext; }) { const { t } = useTranslation("game"); - const { src, isLoading, isRotated, isFlip } = useCardImage(cardName, { - size: "normal", - faceIndex, - isToken: obj?.display_source === "Token", - tokenFilters: obj?.display_source === "Token" ? tokenFiltersForObject(obj) : undefined, - tokenImageRef: obj?.display_source === "Token" ? obj.token_image_ref : undefined, - oracleId: obj?.printed_ref?.oracle_id, - faceName: obj?.printed_ref?.face_name, - sourcePrinting, - }); + const { src, isLoading, isRotated, isFlip } = art; // Issue #6156 on the mobile path: both arms below used to gate the art on // `src &&`, so an artless token (no official paper printing) opened an diff --git a/client/src/components/card/GameCardPreview.tsx b/client/src/components/card/GameCardPreview.tsx index 0a26a03b07..f1bb90f58b 100644 --- a/client/src/components/card/GameCardPreview.tsx +++ b/client/src/components/card/GameCardPreview.tsx @@ -1,9 +1,12 @@ +import { useTranslation } from "react-i18next"; + import { usePreviewDismiss } from "../../hooks/usePreviewDismiss.ts"; import { cardImageLookup } from "../../services/cardImageLookup.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; import { shouldRenderCardBack } from "../../viewmodel/cardProps.ts"; +import { faceDownMarkerName } from "./faceDownMarker.ts"; import { CardPreview } from "./CardPreview.tsx"; /** @@ -18,6 +21,7 @@ import { CardPreview } from "./CardPreview.tsx"; * it from the inspected game object, which is what this component does. */ export function GameCardPreview() { + const { t } = useTranslation("game"); // Lives here (not in GamePageContent) so its inspectedObjectId/previewSticky // subscriptions don't re-render the whole page on every hover. This component // is always mounted, so the dismiss listeners run for the game's full life. @@ -44,15 +48,41 @@ export function GameCardPreview() { // obj.name to the back-face name — cardImageLookup recovers the front name // from obj.back_face. See services/cardImageLookup.ts (issue #90). const inspectedLookup = inspectedObj ? cardImageLookup(inspectedObj) : null; + // A face-down permanent the viewer may look at (their own morph/manifest — + // CR 708.5): the live face is blanked per CR 708.2a, so the PREVIEW is the + // peek — it always shows the stored real face, no matter which face index + // the hover carries (#7547). The battlefield tile keeps the cause marker. + const inspectedPeekedFace = + inspectedObj && !shouldRenderCardBack(inspectedObj) && inspectedObj.face_down + ? (inspectedObj.back_face ?? null) + : null; const inspectedCardName = inspectedObj && !shouldRenderCardBack(inspectedObj) - ? inspectedFaceIndex === 1 && inspectedObj.back_face - ? inspectedObj.back_face.name - : inspectedLookup?.name ?? inspectedObj.name - : null; - // The "other" face: when viewing front, this is back_face; when viewing back, this is the front. - const inspectedOtherFaceName = inspectedObj?.back_face && !shouldRenderCardBack(inspectedObj) - ? inspectedFaceIndex === 1 ? inspectedObj.name : inspectedObj.back_face.name - : null; + ? inspectedPeekedFace + ? inspectedPeekedFace.name + : inspectedFaceIndex === 1 && inspectedObj.back_face + ? inspectedObj.back_face.name + : inspectedLookup?.name ?? inspectedObj.name + : // An OPPONENT's face-down permanent previews as its cause MARKER (full + // size, reminder text included) — the identity stays hidden; the image + // itself resolves inside `CardPreview` from the object's cause (#7547). + // With no marker printing (unknown cause from an older save, or the + // Ixidron class — an effect turned it face down, CR 708.2a) the hover + // still answers: the generic label routes `CardPreview` onto the plain + // card back, which reveals nothing. BATTLEFIELD only — a face-down card + // in a hidden zone (hideaway exile, issue #2889) keeps rendering no + // preview at all: it has no public characteristics the back could stand + // in for, and that row pins exactly this. + (inspectedObj + ? faceDownMarkerName(true, inspectedObj.face_down_cause) + ?? (inspectedObj.zone === "Battlefield" ? t("card.faceDownName") : null) + : null); + // The "other" face: when viewing front, this is back_face; when viewing back, + // this is the front. A face-down permanent has no OTHER printed face — its + // `back_face` is the stored real face already shown by the peek. + const inspectedOtherFaceName = + inspectedObj?.back_face && !shouldRenderCardBack(inspectedObj) && !inspectedPeekedFace + ? inspectedFaceIndex === 1 ? inspectedObj.name : inspectedObj.back_face.name + : null; const previewSuppressed = cardPreviewMode === "shift" && !shiftHeld; diff --git a/client/src/components/card/__tests__/ArtCropCard.test.tsx b/client/src/components/card/__tests__/ArtCropCard.test.tsx index d28f31cd24..4a0cd14c3b 100644 --- a/client/src/components/card/__tests__/ArtCropCard.test.tsx +++ b/client/src/components/card/__tests__/ArtCropCard.test.tsx @@ -254,9 +254,13 @@ describe("ArtCropCard", () => { ); }); - it("renders a face-down permanent's projected identity", () => { + it("backs the tile of the viewer's OWN face-down permanent with its marker (#7547)", () => { + // The engine blanks a face-down permanent's live face (CR 708.2a), so the + // TILE always shows the cause marker — the controller's peek lives in the + // hover preview, not here. The stored real face must not raise the DFC + // badge either: a face-down permanent cannot be a DFC (CR 712.16). mockUseCardImage.mockReturnValue({ - src: "card.png", + src: "morph-marker.png", isLoading: false, isRotated: false, isFlip: false, @@ -264,10 +268,11 @@ describe("ArtCropCard", () => { const permanent = { ...transformedPermanent(), face_down: true, + face_down_cause: "Morph" as const, display_visible_to_viewer: true, - name: "Hidden Sorcery", + name: "", transformed: false, - back_face: null, + back_face: { name: "Hooded Hydra", layout_kind: null } as never, }; useGameStore.setState({ @@ -276,7 +281,8 @@ describe("ArtCropCard", () => { render(); - expect(screen.getByAltText("Hidden Sorcery")).toBeInTheDocument(); + expect(screen.getByAltText("Morph")).toHaveAttribute("src", "morph-marker.png"); + expect(screen.queryByText("DFC")).toBeNull(); }); it("falls back to the card back when face-down marker art fails to load", () => { @@ -305,7 +311,7 @@ describe("ArtCropCard", () => { render(); - const marker = screen.getByAltText("Face-down card"); + const marker = screen.getByAltText("Manifest"); expect(marker).toHaveAttribute( "src", "https://cards.scryfall.io/normal/front/m/a/manifest.jpg", @@ -313,7 +319,7 @@ describe("ArtCropCard", () => { fireEvent.error(marker); - expect(screen.getByAltText("Face-down card")).toHaveAttribute("src", CARD_BACK_URL); + expect(screen.getByAltText("Manifest")).toHaveAttribute("src", CARD_BACK_URL); }); it("keeps loyalty and P/T readable for planeswalkers and creature planeswalkers", () => { diff --git a/client/src/components/card/__tests__/CardPreview.mobileFaceDown.test.tsx b/client/src/components/card/__tests__/CardPreview.mobileFaceDown.test.tsx new file mode 100644 index 0000000000..e3f4fec970 --- /dev/null +++ b/client/src/components/card/__tests__/CardPreview.mobileFaceDown.test.tsx @@ -0,0 +1,111 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, 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 { useUiStore } from "../../../stores/uiStore.ts"; +import { buildGameObject, buildObjectMap } from "../../../test/factories/gameObjectFactory.ts"; +import { buildGameState } from "../../../test/factories/gameStateFactory.ts"; +import { CardPreview } from "../CardPreview.tsx"; + +// The mock src ENCODES which lookup produced it: an oracle-id lookup stamps +// the id, a marker/token lookup stamps the ref's oracle id, a bare name +// lookup stamps the name. The hidden-information assertions below read that +// stamp back — a leaked `printed_ref` becomes a visible "secret-oracle" src. +vi.mock("../../../hooks/useCardImage.ts", () => ({ + useCardImage: vi.fn(( + cardName: string, + options?: { + oracleId?: string; + tokenImageRef?: { scryfall_oracle_id?: string | null } | null; + }, + ) => ({ + src: `${options?.oracleId ?? (options?.tokenImageRef ? `ref:${options.tokenImageRef.scryfall_oracle_id}` : cardName)}.png`, + isLoading: false, + isRotated: false, + isFlip: false, + })), +})); + +vi.mock("../../../hooks/useEngineCardData.ts", () => ({ + useEngineCardData: () => null, + useCardParseDetails: () => null, + useCardRulings: () => [], +})); + +// The mobile overlay is the subject: force the mobile branch. +vi.mock("../../../hooks/useIsMobile.ts", () => ({ + useIsMobile: () => true, +})); + +const SECRET_ORACLE = "secret-oracle-id"; + +function hiddenFaceDown(overrides: Partial = {}): GameObject { + return buildGameObject({ + id: 101, + card_id: 1, + zone: "Battlefield", + name: "", + face_down: true, + // The leak input under test: a wire that still carries the hidden card's + // printing (the engine clears it today — morph.rs pins that — but the + // display must not rely on it; a stale save or future field is enough). + printed_ref: { oracle_id: SECRET_ORACLE, face_name: "Hooded Hydra" } as never, + ...overrides, + }); +} + +function inspect(object: GameObject): void { + useGameStore.setState({ + gameState: buildGameState({ + objects: buildObjectMap(object), + next_object_id: 102, + battlefield: [object.id], + next_timestamp: 2, + }), + spellCosts: {}, + }); + useUiStore.setState({ inspectedObjectId: object.id }); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + useGameStore.setState({ gameState: null, spellCosts: {} }); + useUiStore.getState().dismissPreview(); + useUiStore.setState({ inspectedObjectId: null }); +}); + +describe("CardPreview mobile face-down (hidden information, #7551 review)", () => { + it("previews an opponent's Morph as the marker — never the printed ref", () => { + inspect(hiddenFaceDown({ face_down_cause: "Morph" as never })); + + const { container } = render(); + + const srcs = [...container.querySelectorAll("img")].map((img) => img.getAttribute("src")); + expect(srcs.some((src) => src?.includes(SECRET_ORACLE))).toBe(false); + // The marker ref names the Morph token's oracle id — that IS the image. + expect(srcs.some((src) => src?.startsWith("ref:"))).toBe(true); + for (const call of vi.mocked(useCardImage).mock.calls) { + expect(call[1]?.oracleId).not.toBe(SECRET_ORACLE); + expect(call[1]?.faceName).not.toBe("Hooded Hydra"); + } + }); + + it("previews a markerless face-down as the plain back — no name or ref lookup", () => { + inspect(hiddenFaceDown({ face_down_cause: "TurnedFaceDown" as never })); + + const { container } = render(); + + const srcs = [...container.querySelectorAll("img")].map((img) => img.getAttribute("src")); + expect(srcs).toContain(CARD_BACK_URL); + expect(srcs.some((src) => src?.includes(SECRET_ORACLE))).toBe(false); + // The generic label must never become a card-name search either. + expect(srcs.some((src) => src?.includes("Face-down card.png"))).toBe(false); + for (const call of vi.mocked(useCardImage).mock.calls) { + expect(call[1]?.oracleId).not.toBe(SECRET_ORACLE); + } + }); +}); diff --git a/client/src/components/card/__tests__/GameCardPreview.test.tsx b/client/src/components/card/__tests__/GameCardPreview.test.tsx index b342c3e5ca..74abb85c21 100644 --- a/client/src/components/card/__tests__/GameCardPreview.test.tsx +++ b/client/src/components/card/__tests__/GameCardPreview.test.tsx @@ -260,12 +260,33 @@ describe("GameCardPreview", () => { expect(screen.getAllByAltText("Insectile Aberration").length).toBeGreaterThan(0); }); - it("never previews a face-down permanent (hidden information)", () => { + it("previews a markerless face-down permanent as the generic back — identity stays hidden (#7551 review)", () => { + // No recorded cause (older saves): there is no marker printing, but the + // hover must still answer — with the generic card back, which reveals + // nothing (CR 708.2a: the public face is a blank 2/2). inspect(battlefieldObject({ face_down: true })); - const { container } = render(); + render(); - expect(container.firstChild).toBeNull(); + expect(screen.getAllByAltText("Face-down card").length).toBeGreaterThan(0); + expect(screen.queryByAltText("Pithing Needle")).toBeNull(); + }); + + it("previews the Ixidron class (TurnedFaceDown) as the generic back (#7551 review)", () => { + // `TurnedFaceDown` (an effect turned it face down, CR 708.2a) has no + // printed marker token — same generic-back path as the unknown cause. + inspect( + battlefieldObject({ + face_down: true, + face_down_cause: "TurnedFaceDown" as never, + name: "", + }), + ); + + render(); + + expect(screen.getAllByAltText("Face-down card").length).toBeGreaterThan(0); + expect(screen.queryByAltText("Pithing Needle")).toBeNull(); }); it("previews a face-down permanent when the engine projects its identity", () => { @@ -275,4 +296,38 @@ describe("GameCardPreview", () => { expect(screen.getAllByAltText("Pithing Needle").length).toBeGreaterThan(0); }); + + it("peeks the STORED face of the viewer's own face-down permanent (#7547)", () => { + // The live face is blanked per CR 708.2a; the preview is the CR 708.5 + // peek, so it resolves the stored real face — on any hovered face index. + inspect( + battlefieldObject({ + face_down: true, + display_visible_to_viewer: true, + name: "", + back_face: { name: "Hooded Hydra", layout_kind: null } as never, + }), + ); + + render(); + + expect(screen.getAllByAltText("Hooded Hydra").length).toBeGreaterThan(0); + }); + + it("previews an OPPONENT's face-down permanent as its cause marker (#7547)", () => { + // The identity stays hidden; the marker carries the mechanic's reminder + // text, which is exactly what an opponent may know. + inspect( + battlefieldObject({ + face_down: true, + face_down_cause: "Morph" as never, + name: "", + }), + ); + + render(); + + expect(screen.getAllByAltText("Morph").length).toBeGreaterThan(0); + expect(screen.queryByAltText("Pithing Needle")).toBeNull(); + }); }); diff --git a/client/src/components/card/faceDownMarker.ts b/client/src/components/card/faceDownMarker.ts index 3a793e703b..0d05e659f7 100644 --- a/client/src/components/card/faceDownMarker.ts +++ b/client/src/components/card/faceDownMarker.ts @@ -47,6 +47,27 @@ const MARKERS: Partial> = { // printed for it, so it keeps the generic card back. }; +/** Printed token names, for the tile's name bar and the preview caption. */ +const MARKER_NAMES: Partial> = { + Manifest: "Manifest", + Morph: "Morph", + Cloak: "A Mysterious Creature", + Disguise: "A Mysterious Creature", +}; + +/** + * The printed marker token's NAME for a face-down permanent, or `null` when + * none applies. Shown on the battlefield tile instead of the generic + * "Face-down card" label. + */ +export function faceDownMarkerName( + faceDown: boolean, + cause: FaceDownCause | null | undefined, +): string | null { + if (!faceDown || !cause) return null; + return MARKER_NAMES[cause] ?? null; +} + /** * 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 diff --git a/client/src/viewmodel/cardProps.ts b/client/src/viewmodel/cardProps.ts index 23f2317075..b8bca313de 100644 --- a/client/src/viewmodel/cardProps.ts +++ b/client/src/viewmodel/cardProps.ts @@ -232,8 +232,17 @@ export function formatTypeLine(cardTypes: CardType, keywords?: Keyword[]): strin * discriminant — it ships `layout_kind` on the serialized back face (the same * value `engine::game::transform::is_double_faced_permanent` keys on). */ -export function hasOtherPrintedFace(obj: Pick): boolean { - return obj.back_face != null && obj.back_face.layout_kind !== "Flip"; +export function hasOtherPrintedFace( + obj: Pick, +): boolean { + // CR 712.16: a double-faced permanent can't be face down — a face-down + // permanent's `back_face` is its STORED REAL FACE (morph/manifest), not + // another printed face, so it must not raise the DFC affordance (#7547). + return ( + obj.face_down !== true && + obj.back_face != null && + obj.back_face.layout_kind !== "Flip" + ); } export function computePTDisplay(obj: GameObject): PTDisplay | null {