From 482bad71cd5eb676aaae57f4e988d16f5f724352 Mon Sep 17 00:00:00 2001 From: Rajah James Date: Thu, 9 Jul 2026 18:22:44 -0400 Subject: [PATCH 1/2] fix(frontend): surface Glasses of Urza's private hand-look to the caster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glasses of Urza ({T}: Look at target player's hand. — verified via Scryfall) and Gitaxian Probe already parse and resolve correctly on the engine side: Effect::RevealHand { reveal: false } populates state.private_look_ids / private_look_player (CR 701.20e), and the filtered GameState sent to the looking player already carries the real card names for those ids. The gap is entirely in the frontend. OpponentHand.tsx's isRevealed check only consulted the public reveal sets (revealed_cards, public_revealed_cards), never the private-look fields, so the looker's own client kept rendering card backs for a hand it could already see. isLibraryCardRevealedToViewer (gameStateView.ts) already implements the identical CR 701.20e check for library peeks (Mishra's Bauble, scry) — its own doc comment even claimed this was "the same pattern OpponentHand uses for opponent hand cards," which wasn't true. Extracted the private- look half into a shared, zone-agnostic isPrivatelyLookedAtByViewer (private_look_ids carries object ids regardless of zone) and wired it into OpponentHand's reveal check. Added regression tests exercising both the positive case (the looker sees the card without showCards) and the negative case (a different player does not), verified via live-revert-and-rerun. Fixes phase-rs/phase#5251. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K --- client/src/components/hand/OpponentHand.tsx | 7 +++-- .../hand/__tests__/OpponentHand.test.tsx | 27 +++++++++++++++++++ client/src/viewmodel/gameStateView.ts | 25 ++++++++++++++--- 3 files changed, 53 insertions(+), 6 deletions(-) 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..bf4be6b15a 100644 --- a/client/src/viewmodel/gameStateView.ts +++ b/client/src/viewmodel/gameStateView.ts @@ -113,6 +113,26 @@ 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, +): boolean { + if (!gameState) 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 +159,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); } /** From 22ba6ba6d45fb8a1096a1c74dec0a77977b40e4b Mon Sep 17 00:00:00 2001 From: Rajah James Date: Thu, 9 Jul 2026 19:53:48 -0400 Subject: [PATCH 2/2] fix(frontend): guard isPrivatelyLookedAtByViewer against a nullish viewer gemini-code-assist review on #5464: if a caller's viewerId hasn't resolved yet (mid-load) and gameState.private_look_player also happens to be unset, the bare === would match undefined against undefined and incorrectly report the look as visible. Widen viewerId's type and short-circuit on null/undefined before the comparison. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K --- client/src/viewmodel/gameStateView.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/src/viewmodel/gameStateView.ts b/client/src/viewmodel/gameStateView.ts index bf4be6b15a..baa34e8096 100644 --- a/client/src/viewmodel/gameStateView.ts +++ b/client/src/viewmodel/gameStateView.ts @@ -124,9 +124,12 @@ export function getPlayerZoneIds( export function isPrivatelyLookedAtByViewer( gameState: GameState | null, objectId: ObjectId, - viewerId: PlayerId, + viewerId: PlayerId | null | undefined, ): boolean { - if (!gameState) return false; + // 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)