Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
24 changes: 20 additions & 4 deletions client/src/components/card/CardPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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 { 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,6 +316,16 @@ 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);
// 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.
Expand All @@ -324,11 +336,15 @@ function CardPreviewInner({
const { src, isLoading, isRotated, isFlip } = useCardImage(cardName, {
size: "normal",
faceIndex: defaultFaceIndex,
isToken,
isToken: isToken || markerIsPrimary,
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,
tokenImageRef: markerIsPrimary
? previewMarkerRef
: isToken && obj
? obj.token_image_ref
: undefined,
oracleId: markerIsPrimary ? undefined : obj?.printed_ref?.oracle_id,
faceName: markerIsPrimary ? undefined : obj?.printed_ref?.face_name,
scryfallId,
sourcePrinting,
});
Expand Down
35 changes: 27 additions & 8 deletions client/src/components/card/GameCardPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 Down Expand Up @@ -44,15 +45,33 @@ 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).
(inspectedObj
? faceDownMarkerName(true, inspectedObj.face_down_cause)
: 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
34 changes: 34 additions & 0 deletions client/src/components/card/__tests__/GameCardPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,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(<GameCardPreview />);

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(<GameCardPreview />);

expect(screen.getAllByAltText("Morph").length).toBeGreaterThan(0);
expect(screen.queryByAltText("Pithing Needle")).toBeNull();
});
});
21 changes: 21 additions & 0 deletions client/src/components/card/faceDownMarker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ const MARKERS: Partial<Record<FaceDownCause, TokenImageRef>> = {
// 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<Record<FaceDownCause, string>> = {
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
Expand Down
13 changes: 11 additions & 2 deletions client/src/viewmodel/cardProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameObject, "back_face">): boolean {
return obj.back_face != null && obj.back_face.layout_kind !== "Flip";
export function hasOtherPrintedFace(
obj: Pick<GameObject, "back_face" | "face_down">,
): 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 {
Expand Down
Loading