From c3cd5266ac81091e320ad58858689f8349195060 Mon Sep 17 00:00:00 2001 From: kiannidev Date: Wed, 1 Jul 2026 17:49:13 -0700 Subject: [PATCH 1/2] Fix Hideaway exile ghost visibility for opponents on the battlefield. ExileGhostCard on hideaway permanents now uses the same look-permission gate as ZoneViewer so opponents see a face-down placeholder instead of the hidden card identity. Fixes #4828 --- client/src/components/board/PermanentCard.tsx | 16 +- .../issue_4828_hideaway_exile_ghost.test.tsx | 155 ++++++++++++++++++ 2 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 client/src/components/board/__tests__/issue_4828_hideaway_exile_ghost.test.tsx diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index e777a4e355..9c63cccebf 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -19,6 +19,7 @@ import { useGameStore } from "../../stores/gameStore.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; import { buildGrantedKeywordSources, buildPTSources } from "../../viewmodel/attribution.ts"; +import { isFaceDownExileCardVisibleToViewer } from "../../viewmodel/gameStateView.ts"; import { COUNTER_COLORS, computePTDisplay, formatCounterTooltip, formatCounterType, toRoman } from "../../viewmodel/cardProps.ts"; import { getCardDisplayColors } from "../card/cardFrame.ts"; import { useBoardInteractionState } from "./BoardInteractionContext.tsx"; @@ -926,7 +927,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( @@ -935,6 +938,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, @@ -955,9 +961,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..aed1dff05d --- /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", shards: [], generic: 0 }, + 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(); + }); +}); From d1a502eba803be33a9223ced4b8c244759445bdb Mon Sep 17 00:00:00 2001 From: kiannidev Date: Thu, 2 Jul 2026 05:04:23 +0200 Subject: [PATCH 2/2] Fix Hideaway exile ghost test mana_cost type for frontend CI. Use the discriminated NoCost shape without Cost-only fields so type-check passes in the frontend workflow. Co-authored-by: Cursor --- .../board/__tests__/issue_4828_hideaway_exile_ghost.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index aed1dff05d..44dad6cdb6 100644 --- 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 @@ -36,7 +36,7 @@ function makeObject(overrides: Partial = {}): GameObject { toughness: null, loyalty: null, card_types: { supertypes: [], core_types: ["Land"], subtypes: [] }, - mana_cost: { type: "NoCost", shards: [], generic: 0 }, + mana_cost: { type: "NoCost" }, keywords: [], abilities: [], trigger_definitions: [],