Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions client/src/components/hand/OpponentHand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions client/src/components/hand/__tests__/OpponentHand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<OpponentHand playerId={2} />);

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(<OpponentHand playerId={2} />);

expect(screen.queryByAltText("Explicit Opponent Card")).toBeNull();
});
});
28 changes: 24 additions & 4 deletions client/src/viewmodel/gameStateView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
Comment on lines +124 to +137

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

[MEDIUM] Potential state leak or type error when viewerId is null or undefined. Evidence: client/src/viewmodel/gameStateView.ts:124-134.

Why it matters: If viewerId is null/undefined (e.g., for spectators or during loading states) and gameState.private_look_player is also null/undefined, they will match and potentially leak private information or cause type-checking errors.

Suggested fix: Update viewerId type to PlayerId | null | undefined and add a viewerId == null guard.

Suggested change
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)
);
}
export function isPrivatelyLookedAtByViewer(
gameState: GameState | null,
objectId: ObjectId,
viewerId: PlayerId | null | undefined,
): boolean {
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`.
*
Expand All @@ -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);
}

/**
Expand Down
Loading