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);
}
/**