Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
64 changes: 51 additions & 13 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,51 @@ 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.
const genericFaceDownBack =
obj != null && 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 +413,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
43 changes: 35 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,38 @@ 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.
(inspectedObj
? faceDownMarkerName(true, inspectedObj.face_down_cause)
?? t("card.faceDownName")
: 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