Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions client/src/components/board/AttachmentFan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)]"
/>
Expand Down
10 changes: 8 additions & 2 deletions client/src/components/board/PermanentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2072,15 +2072,17 @@ 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;
useGameStore.setState({ gameState, waitingFor: gameState.waiting_for });

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", () => {
Expand Down
13 changes: 9 additions & 4 deletions client/src/components/card/ArtCropCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 };
Expand Down
6 changes: 4 additions & 2 deletions client/src/components/card/CardImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="relative inline-block w-fit select-none">
Expand Down
96 changes: 64 additions & 32 deletions client/src/components/card/CardPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -314,24 +317,56 @@ 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.
const isTransformed = obj?.transformed ?? false;
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,
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const classLevel = obj?.class_level;
const previewRef = useRef<HTMLDivElement | null>(null);
const pointerRef = useRef<{ x: number; y: number } | null>(null);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -687,10 +730,8 @@ function CardPreviewInner({
<MobilePreviewOverlay
cardName={cardName}
backFaceName={backFaceName}
faceIndex={defaultFaceIndex}
obj={obj}
art={{ src: activeSrc, isLoading: activeLoading, isRotated: activeRotated, isFlip }}
onDismiss={onDismiss ?? dismissPreview}
sourcePrinting={sourcePrinting}
layout={mobileLayout ?? "modal"}
report={reportContext}
/>
Expand Down Expand Up @@ -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
Expand Down
46 changes: 38 additions & 8 deletions client/src/components/card/GameCardPreview.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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.
Expand All @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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;

Expand Down
20 changes: 13 additions & 7 deletions client/src/components/card/__tests__/ArtCropCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,20 +254,25 @@ 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,
});
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({
Expand All @@ -276,7 +281,8 @@ describe("ArtCropCard", () => {

render(<ArtCropCard objectId={101} />);

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", () => {
Expand Down Expand Up @@ -305,15 +311,15 @@ describe("ArtCropCard", () => {

render(<ArtCropCard objectId={101} />);

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",
);

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", () => {
Expand Down
Loading
Loading