diff --git a/client/src/components/hand/OpponentHand.tsx b/client/src/components/hand/OpponentHand.tsx index 142940e4c2..93099289b4 100644 --- a/client/src/components/hand/OpponentHand.tsx +++ b/client/src/components/hand/OpponentHand.tsx @@ -10,7 +10,7 @@ import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; import { usePerspectivePlayerId } from "../../hooks/usePlayerId.ts"; import type { ObjectId, PlayerId } from "../../adapter/types.ts"; -import { getOpponentIds, resolveFocusedOpponent } from "../../viewmodel/gameStateView.ts"; +import { getOpponentIds, isPrivatelyLookedAtByViewer, resolveFocusedOpponent } from "../../viewmodel/gameStateView.ts"; interface OpponentHandProps { playerId?: PlayerId; @@ -60,7 +60,10 @@ export function OpponentHand({ playerId, showCards = false, layout = "default" } {opponent.hand.map((id, i) => { const obj = objects ? objects[id] : null; const isRevealed = (revealedCards?.includes(id) ?? false) - || (publicRevealedCards?.includes(id) ?? false); + || (publicRevealedCards?.includes(id) ?? false) + // CR 701.20e: Glasses of Urza / Gitaxian Probe "look at target + // player's hand" surfaces the card's identity only to the looker. + || isPrivatelyLookedAtByViewer(gameState ?? null, id, myId); const showFace = showCards || isRevealed; // Negate rotation so fan opens toward opponent (top of screen) const rotation = -((i - center) * rotationStep); diff --git a/client/src/components/hand/__tests__/OpponentHand.test.tsx b/client/src/components/hand/__tests__/OpponentHand.test.tsx index 70bd3907ab..b1a9cdd847 100644 --- a/client/src/components/hand/__tests__/OpponentHand.test.tsx +++ b/client/src/components/hand/__tests__/OpponentHand.test.tsx @@ -72,4 +72,31 @@ describe("OpponentHand", () => { expect(screen.getByAltText("Explicit Opponent Card")).toBeInTheDocument(); expect(screen.queryByAltText("Focused Opponent Card")).toBeNull(); }); + + // CR 701.20e (phase-rs/phase#5251): Glasses of Urza / Gitaxian Probe "look at + // target player's hand" surfaces the looked-at cards' identities only to the + // looking player (`private_look_player`/`private_look_ids`), distinct from + // the public reveal sets already covered above. Before this fix, the + // opponent-hand card thumbnail only consulted `revealed_cards` / + // `public_revealed_cards`, so a private look never made the card visible to + // the looker even though the engine had already sent them its real name. + it("shows a card the engine privately looked at for this viewer, without showCards", () => { + useGameStore.setState({ + gameState: { ...createGameState(), private_look_player: 0, private_look_ids: [22] }, + }); + + render(); + + expect(screen.getByAltText("Explicit Opponent Card")).toBeInTheDocument(); + }); + + it("does not show a privately-looked-at card to a player other than the looker", () => { + useGameStore.setState({ + gameState: { ...createGameState(), private_look_player: 1, private_look_ids: [22] }, + }); + + render(); + + expect(screen.queryByAltText("Explicit Opponent Card")).toBeNull(); + }); }); diff --git a/client/src/viewmodel/gameStateView.ts b/client/src/viewmodel/gameStateView.ts index 0ce0ecfbd5..baa34e8096 100644 --- a/client/src/viewmodel/gameStateView.ts +++ b/client/src/viewmodel/gameStateView.ts @@ -113,6 +113,29 @@ export function getPlayerZoneIds( return gameState.exile.filter((id) => gameState.objects[id]?.owner === playerId); } +/** + * CR 701.20e: whether the engine's private "look at" set (Mishra's Bauble at an + * opponent's library, a scry look, Glasses of Urza / Gitaxian Probe at a hand) + * surfaces `objectId`'s identity to `viewerId`. Zone-agnostic — the engine's + * `private_look_ids`/`private_look_player` fields carry object ids regardless + * of which zone those objects sit in, so this same check backs both the + * library viewer and the opponent-hand viewer below. + */ +export function isPrivatelyLookedAtByViewer( + gameState: GameState | null, + objectId: ObjectId, + viewerId: PlayerId | null | undefined, +): boolean { + // Guard against `undefined === undefined`: if the caller hasn't resolved a + // real viewer yet (e.g. mid-load) and `private_look_player` also happens to + // be unset, a bare `===` would match and leak the private look to nobody. + if (!gameState || viewerId == null) return false; + return ( + gameState.private_look_player === viewerId && + (gameState.private_look_ids?.includes(objectId) ?? false) + ); +} + /** * Whether the engine has revealed a given library card's identity to `viewerId`. * @@ -139,10 +162,7 @@ export function isLibraryCardRevealedToViewer( // CR 701.20e: a private "look at the top card" (Mishra's Bauble at an // opponent's library; your own scry look) surfaces the peeked ids only to the // looking player. - return ( - gameState.private_look_player === viewerId && - (gameState.private_look_ids?.includes(objectId) ?? false) - ); + return isPrivatelyLookedAtByViewer(gameState, objectId, viewerId); } /**