diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx
index a70ac3b22f..bc7b35bbf0 100644
--- a/client/src/components/board/PermanentCard.tsx
+++ b/client/src/components/board/PermanentCard.tsx
@@ -30,6 +30,7 @@ import {
boardChoiceMaxSelection,
buildBoardChoiceAction,
getBoardChoiceView,
+ isFaceDownExileCardVisibleToViewer,
isBoardChoiceImmediate,
type BoardChoiceIntent,
} from "../../viewmodel/gameStateView.ts";
@@ -1045,7 +1046,9 @@ interface ExileGhostCardProps {
}
const ExileGhostCard = memo(function ExileGhostCard({ objectId, offset }: ExileGhostCardProps) {
- const obj = useGameStore((s) => s.gameState?.objects[objectId]);
+ const gameState = useGameStore((s) => s.gameState);
+ const obj = gameState?.objects[objectId];
+ const viewerId = usePlayerId();
const { handlers: hoverHandlers } = useCardHover(objectId);
const battlefieldCardDisplay = usePreferencesStore((s) => s.battlefieldCardDisplay);
const controllerIdentity = useGameStore(
@@ -1054,6 +1057,9 @@ const ExileGhostCard = memo(function ExileGhostCard({ objectId, offset }: ExileG
if (!obj) return null;
+ const hiddenFromViewer =
+ obj.face_down && !isFaceDownExileCardVisibleToViewer(gameState ?? null, obj, viewerId);
+
const isLand = obj.card_types.core_types.includes("Land");
const displayColors = getCardDisplayColors(
obj.color,
@@ -1074,9 +1080,13 @@ const ExileGhostCard = memo(function ExileGhostCard({ objectId, offset }: ExileG
{/* Purple exile tint */}
{useArtCrop ? (
-
+ hiddenFromViewer ? (
+
+ ) : (
+
+ )
) : (
-
+
)}
);
diff --git a/client/src/components/board/__tests__/issue_4828_hideaway_exile_ghost.test.tsx b/client/src/components/board/__tests__/issue_4828_hideaway_exile_ghost.test.tsx
new file mode 100644
index 0000000000..44dad6cdb6
--- /dev/null
+++ b/client/src/components/board/__tests__/issue_4828_hideaway_exile_ghost.test.tsx
@@ -0,0 +1,155 @@
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { GameObject, GameState } from "../../../adapter/types.ts";
+import { useGameStore } from "../../../stores/gameStore.ts";
+import { BoardInteractionContext } from "../BoardInteractionContext.tsx";
+import { PermanentCard } from "../PermanentCard.tsx";
+
+vi.mock("../../card/CardImage.tsx", () => ({
+ CardImage: ({ cardName, faceDown }: { cardName: string; faceDown?: boolean }) => (
+
+ ),
+}));
+
+function makeObject(overrides: Partial = {}): GameObject {
+ return {
+ id: 1,
+ card_id: 100,
+ owner: 0,
+ controller: 0,
+ zone: "Battlefield",
+ tapped: false,
+ face_down: false,
+ flipped: false,
+ transformed: false,
+ damage_marked: 0,
+ dealt_deathtouch_damage: false,
+ attached_to: null,
+ attachments: [],
+ counters: {},
+ name: "Host",
+ power: null,
+ toughness: null,
+ loyalty: null,
+ card_types: { supertypes: [], core_types: ["Land"], subtypes: [] },
+ mana_cost: { type: "NoCost" },
+ keywords: [],
+ abilities: [],
+ trigger_definitions: [],
+ replacement_definitions: [],
+ static_definitions: [],
+ color: [],
+ base_power: null,
+ base_toughness: null,
+ base_keywords: [],
+ base_color: [],
+ timestamp: 1,
+ entered_battlefield_turn: null,
+ ...overrides,
+ };
+}
+
+function makeState(objects: GameObject[], exileLinks: NonNullable): GameState {
+ return {
+ active_player: 0,
+ priority_player: 0,
+ players: [
+ {
+ id: 0,
+ life: 20,
+ poison_counters: 0,
+ mana_pool: { mana: [] },
+ library: [],
+ hand: [],
+ graveyard: [],
+ has_drawn_this_turn: false,
+ lands_played_this_turn: 0,
+ turns_taken: 0,
+ },
+ {
+ id: 1,
+ life: 20,
+ poison_counters: 0,
+ mana_pool: { mana: [] },
+ library: [],
+ hand: [],
+ graveyard: [],
+ has_drawn_this_turn: false,
+ lands_played_this_turn: 0,
+ turns_taken: 0,
+ },
+ ],
+ objects: Object.fromEntries(objects.map((o) => [o.id, o])),
+ battlefield: objects.filter((o) => o.zone === "Battlefield").map((o) => o.id),
+ exile: objects.filter((o) => o.zone === "Exile").map((o) => o.id),
+ stack: [],
+ combat: null,
+ exile_links: exileLinks,
+ waiting_for: { type: "Priority", data: { player: 0 } },
+ } as unknown as GameState;
+}
+
+describe("PermanentCard Hideaway exile ghost (issue #4828)", () => {
+ afterEach(() => cleanup());
+
+ beforeEach(() => {
+ window.matchMedia = ((query: string) => ({
+ matches: query === "(any-hover: hover)",
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })) as unknown as typeof window.matchMedia;
+ });
+
+ it("hides an opponent's Hideaway-exiled card on the source permanent's exile ghost", () => {
+ const source = makeObject({
+ id: 1,
+ owner: 1,
+ controller: 1,
+ name: "Mosswort Bridge",
+ });
+ const hidden = makeObject({
+ id: 2,
+ owner: 1,
+ controller: 1,
+ zone: "Exile",
+ name: "Ghalta, Primal Hunter",
+ face_down: true,
+ });
+ const gameState = makeState(
+ [source, hidden],
+ [{ exiled_id: 2, source_id: 1, kind: "HideawayLookable" }],
+ );
+ useGameStore.setState({ gameState, waitingFor: gameState.waiting_for });
+
+ const { queryByLabelText } = render(
+
+
+ ,
+ );
+
+ expect(queryByLabelText("Face-down card")).not.toBeNull();
+ expect(queryByLabelText("Ghalta, Primal Hunter")).toBeNull();
+ });
+});