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(); + }); +});