Skip to content
Merged
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
3 changes: 3 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,9 @@ export interface GameObject {
owner: PlayerId;
controller: PlayerId;
zone: Zone;
/** Engine-projected identity visibility for the current viewer. Omitted/false
* means the display layer must not show this card's face or name. */
display_visible_to_viewer?: boolean;
tapped: boolean;
face_down: boolean;
flipped: boolean;
Expand Down
11 changes: 2 additions & 9 deletions client/src/components/hand/OpponentHand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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, isPrivatelyLookedAtByViewer, resolveFocusedOpponent } from "../../viewmodel/gameStateView.ts";
import { getOpponentIds, resolveFocusedOpponent } from "../../viewmodel/gameStateView.ts";
import {
OPPONENT_CARD_SCALE,
OPPONENT_HAND_VERTICAL_SCALE,
Expand Down Expand Up @@ -40,8 +40,6 @@ export function OpponentHand({ playerId, showCards = false, layout = "default" }
?? (myId === 0 ? 1 : 0);
const opponent = players?.[opponentId];
const objects = useGameStore((s) => s.gameState?.objects);
const revealedCards = useGameStore((s) => s.gameState?.revealed_cards);
const publicRevealedCards = useGameStore((s) => s.gameState?.public_revealed_cards);

if (!opponent) return null;

Expand Down Expand Up @@ -77,12 +75,7 @@ export function OpponentHand({ playerId, showCards = false, layout = "default" }
<AnimatePresence>
{opponent.hand.map((id, i) => {
const obj = objects ? objects[id] : null;
const isRevealed = (revealedCards?.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;
const showFace = showCards || (obj?.display_visible_to_viewer ?? false);
const rotation = -fan.rotation(i);
const arcOffset = fan.arc(i);

Expand Down
20 changes: 10 additions & 10 deletions client/src/components/hand/__tests__/OpponentHand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,24 +148,24 @@ describe("OpponentHand", () => {
);
});

// 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", () => {
it("shows a card when Rust projects its display visibility, without showCards", () => {
const state = createGameState();
useGameStore.setState({
gameState: { ...createGameState(), private_look_player: 0, private_look_ids: [22] },
gameState: {
...state,
objects: {
...state.objects,
22: { ...state.objects[22], display_visible_to_viewer: true },
},
},
});

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", () => {
it("does not reconstruct visibility from a private-look payload", () => {
useGameStore.setState({
gameState: { ...createGameState(), private_look_player: 1, private_look_ids: [22] },
});
Expand Down
12 changes: 3 additions & 9 deletions client/src/components/zone/LibraryPile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,10 @@ export function LibraryPile({ playerId, size, onView }: LibraryPileProps) {
});
const topCardName = useGameStore((s) => {
if (topObjectId == null) return null;
const peek =
playerId === myId &&
(s.gameState?.players[playerId]?.can_look_at_top_of_library ?? false);
// Gate visibility on the engine's reveal sets (mirrors OpponentHand), never
// on name redaction: single-player renders the raw, unredacted state, so the
// top card name is present even for an opponent's hidden top. CR 701.20b
// (public reveal) and CR 701.20e (private look, e.g. Mishra's Bauble) are
// the only windows that expose an opponent's top.
// Rust has already resolved public reveals, private looks, and continuous
// top-card permissions into the per-object display projection.
const revealedToMe = isLibraryCardRevealedToViewer(s.gameState ?? null, topObjectId, myId);
if (!peek && !revealedToMe) return null;
if (!revealedToMe) return null;
return s.gameState?.objects[topObjectId]?.name ?? null;
});

Expand Down
23 changes: 3 additions & 20 deletions client/src/components/zone/ZoneViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,27 +63,10 @@ export function ZoneViewer({ zone, playerId, onClose }: ZoneViewerProps) {
const cards = useMemo(() => {
if (!objects) return [];
const resolved = zoneIds.map((id) => objects[id]).filter(Boolean) as GameObject[];
// CR 701.20: the library viewer shows only the cards the engine has revealed
// to this viewer (top-of-library reveals + private looks), top-first.
// Unrevealed cards are omitted entirely — visibility is gated on the engine's
// reveal sets, never inferred from name redaction (single-player renders the
// raw, unredacted state).
// The library viewer shows only the cards whose identities Rust projected
// for this viewer, top-first. Unrevealed cards are omitted entirely.
if (zone === "library") {
// The "look at the top card of your library" capability (Future Sight,
// Bolas's Citadel, Oracle of Mul Daya) is a continuous static that exposes
// the OWNER's own top card without adding it to revealed_cards/private_look
// — mirror LibraryPile's `peek` clause so that top still shows (and stays
// castable) through the modal.
const ownTopId =
viewerId === playerId &&
(gameState?.players[playerId]?.can_look_at_top_of_library ?? false)
? gameState?.players[playerId]?.library?.[0]
: undefined;
return resolved.filter(
(obj) =>
isLibraryCardRevealedToViewer(gameState, obj.id, viewerId) ||
obj.id === ownTopId,
);
return resolved.filter((obj) => isLibraryCardRevealedToViewer(gameState, obj.id, viewerId));
}
return resolved;
}, [objects, zoneIds, zone, gameState, viewerId, playerId]);
Expand Down
19 changes: 7 additions & 12 deletions client/src/components/zone/__tests__/LibraryPile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function setStore({
actions: GameAction[];
}) {
const top = makeObject(topCardId, topCardName);
top.display_visible_to_viewer = canPeek;
const gameState = buildGameState({
active_player: 0,
objects: buildObjectMap(top),
Expand Down Expand Up @@ -100,12 +101,12 @@ function setOpponentLibraryTop(
topCardName: string,
reveal: {
revealedCards?: number[];
privateLookPlayer?: number;
privateLookIds?: number[];
displayVisible?: boolean;
} = {},
) {
const topCardId = 77;
const top = makeObject(topCardId, topCardName);
top.display_visible_to_viewer = reveal.displayVisible ?? false;
const gameState = buildGameState({
active_player: 0,
objects: buildObjectMap(top),
Expand All @@ -124,8 +125,6 @@ function setOpponentLibraryTop(
exile: [],
stack: [],
revealed_cards: reveal.revealedCards ?? [],
private_look_player: reveal.privateLookPlayer,
private_look_ids: reveal.privateLookIds ?? [],
waiting_for: buildPriorityWaitingFor(),
});

Expand Down Expand Up @@ -193,10 +192,8 @@ describe("LibraryPile play/cast surfacing (#297)", () => {
});
});

it("shows opponent library top after a private look peek (Mishra's Bauble)", () => {
// CR 701.20e: I (player 0) privately look at the opponent's (player 1) top.
// The engine records the look in private_look_player/ids; the pile shows it.
setOpponentLibraryTop("Lightning Bolt", { privateLookPlayer: 0, privateLookIds: [77] });
it("shows an opponent library top when Rust projects it as visible", () => {
setOpponentLibraryTop("Lightning Bolt", { displayVisible: true });
render(<LibraryPile playerId={1} />);
const button = screen.getByRole("button", { name: /library \(1 card\)/i });
expect(button).toBeInTheDocument();
Expand All @@ -216,10 +213,8 @@ describe("LibraryPile play/cast surfacing (#297)", () => {
expect(screen.getByAltText("Library")).toBeInTheDocument();
});

it("shows an opponent library top that is publicly revealed (revealed_cards)", () => {
// CR 701.20b: opponent's own public reveal (Oracle of Mul Daya) — visible to
// all players via revealed_cards, so the pile shows it with the amber border.
setOpponentLibraryTop("Lightning Bolt", { revealedCards: [77] });
it("uses reveal state only for the public-reveal treatment, not visibility", () => {
setOpponentLibraryTop("Lightning Bolt", { revealedCards: [77], displayVisible: true });
render(<LibraryPile playerId={1} />);
const button = screen.getByRole("button", { name: /library \(1 card\)/i });
expect(button.className).toContain("border-amber-500");
Expand Down
16 changes: 7 additions & 9 deletions client/src/components/zone/__tests__/ZoneViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,25 +133,23 @@ describe("ZoneViewer", () => {
});

it("shows only the engine-revealed library cards, omitting unrevealed ones", () => {
// CR 701.20b: a RevealTop / "play with top revealed" surfaces specific top
// cards via `revealed_cards`. Visibility is gated on that engine set, NOT on
// the card name — single-player renders the raw, unredacted state, so the
// unrevealed cards below carry real names yet must NOT appear in the viewer.
// Rust projects identity visibility per object. Real names alone never make
// a library card render in the viewer.
const revealed = makeObject({
id: 20,
zone: "Library",
name: "Llanowar Elves",
display_visible_to_viewer: true,
keywords: [],
base_keywords: [],
});
// Real names, but absent from revealed_cards → must be filtered out.
// Real names, but no engine display projection → must be filtered out.
const unrevealedA = makeObject({ id: 21, zone: "Library", name: "Black Lotus" });
const unrevealedB = makeObject({ id: 22, zone: "Library", name: "Mox Sapphire" });
const base = makeState(revealed);
const gameState = {
...base,
objects: buildObjectMap(revealed, unrevealedA, unrevealedB),
revealed_cards: [revealed.id],
players: [
{ ...base.players[0], graveyard: [], library: [revealed.id, unrevealedA.id, unrevealedB.id] },
base.players[1],
Expand Down Expand Up @@ -187,6 +185,7 @@ describe("ZoneViewer", () => {
id: 30,
zone: "Library",
name: "Mystic Sanctuary",
display_visible_to_viewer: true,
keywords: [],
base_keywords: [],
});
Expand All @@ -196,7 +195,6 @@ describe("ZoneViewer", () => {
const gameState = {
...base,
objects: buildObjectMap(revealed, unrevealed),
revealed_cards: [revealed.id],
players: [
{ ...base.players[0], graveyard: [], library: [revealed.id, unrevealed.id] },
base.players[1],
Expand Down Expand Up @@ -231,6 +229,7 @@ describe("ZoneViewer", () => {
id: 50,
zone: "Library",
name: "Future Sight Top",
display_visible_to_viewer: true,
keywords: [],
base_keywords: [],
});
Expand All @@ -240,7 +239,6 @@ describe("ZoneViewer", () => {
const gameState = {
...base,
objects: buildObjectMap(top, buried),
revealed_cards: [],
players: [
{
...base.players[0],
Expand Down Expand Up @@ -290,6 +288,7 @@ describe("ZoneViewer", () => {
controller: 1,
zone: "Library",
name: "Courser of Kruphix",
display_visible_to_viewer: true,
keywords: [],
base_keywords: [],
});
Expand All @@ -304,7 +303,6 @@ describe("ZoneViewer", () => {
const gameState = {
...base,
objects: buildObjectMap(revealed, unrevealed),
revealed_cards: [revealed.id],
players: [
{ ...base.players[0], graveyard: [] },
{ ...base.players[1], graveyard: [], library: [revealed.id, unrevealed.id] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ describe("ZoneViewer exile face-down visibility (issue #2889)", () => {
zone: "Exile",
name: "Ghalta, Primal Hunter",
face_down: true,
display_visible_to_viewer: true,
printed_ref: { oracle_id: "ghalta-oracle", face_name: "Ghalta, Primal Hunter" },
});
const gameState = makeState(
Expand Down Expand Up @@ -251,6 +252,7 @@ describe("ZoneViewer exile face-down visibility (issue #2889)", () => {
name: "Alrund's Epiphany",
face_down: true,
foretold: true,
display_visible_to_viewer: true,
printed_ref: { oracle_id: "epiphany-oracle", face_name: "Alrund's Epiphany" },
});
const gameState = makeState([ownForetold]);
Expand Down
58 changes: 8 additions & 50 deletions client/src/viewmodel/__tests__/gameStateView.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import type { ExileLinkKind, GameAction, GameObject, GameState, PlayerId, WaitingFor } from "../../adapter/types";
import type { GameAction, GameObject, GameState, PlayerId, WaitingFor } from "../../adapter/types";
import {
buildGameObject,
buildGameObjectWithCoreTypes,
Expand Down Expand Up @@ -699,11 +699,6 @@ describe("getOpponentIds", () => {
});
});

// Issue #2889: single-player renders the raw, unredacted state, so a
// Hideaway/Foretell face-down exile's real `name`/`printed_ref` sit on the
// object regardless of viewer. This helper is the client-side half of the
// engine's `hidden_facedown_exile_ids` look-permission gate
// (crates/engine/src/game/visibility.rs, CR 406.3 + CR 702.75a + CR 702.143e).
describe("isFaceDownExileCardVisibleToViewer", () => {
function faceDownObject(overrides: Partial<GameObject> = {}): GameObject {
return buildGameObjectWithCoreTypes(["Creature"], {
Expand All @@ -720,54 +715,17 @@ describe("isFaceDownExileCardVisibleToViewer", () => {
});
}

function stateWithSourceAndExiled(
source: GameObject,
exiled: GameObject,
kind: ExileLinkKind,
): GameState {
return buildGameState({
objects: buildObjectMap(source, exiled),
exile_links: [{ exiled_id: exiled.id, source_id: source.id, kind }],
});
}

it("is false for a card that isn't face down", () => {
const obj = faceDownObject({ face_down: false });
const obj = faceDownObject({ face_down: false, display_visible_to_viewer: true });
expect(isFaceDownExileCardVisibleToViewer(buildGameState({ objects: {} }), obj, 1)).toBe(false);
});

it("is true for the controller of the Hideaway permanent that exiled it", () => {
const source: GameObject = { ...faceDownObject(), id: 1, zone: "Battlefield", face_down: false };
const exiled = faceDownObject();
const state = stateWithSourceAndExiled(source, exiled, "HideawayLookable");
expect(isFaceDownExileCardVisibleToViewer(state, exiled, 1)).toBe(true);
});

it("is false for an opponent of the Hideaway permanent's controller", () => {
const source: GameObject = { ...faceDownObject(), id: 1, zone: "Battlefield", face_down: false };
const exiled = faceDownObject();
const state = stateWithSourceAndExiled(source, exiled, "HideawayLookable");
expect(isFaceDownExileCardVisibleToViewer(state, exiled, 0)).toBe(false);
});

it("is false for a plain TrackedBySource link even for the source's controller", () => {
// Bomat Courier ("(You can't look at it.)") tracks its face-down exile by
// source for later retrieval but grants no look-permission.
const source: GameObject = { ...faceDownObject(), id: 1, zone: "Battlefield", face_down: false };
const exiled = faceDownObject();
const state = stateWithSourceAndExiled(source, exiled, "TrackedBySource");
expect(isFaceDownExileCardVisibleToViewer(state, exiled, 1)).toBe(false);
});

it("is true for the owner of a foretold card", () => {
const exiled = faceDownObject({ owner: 0, controller: 0, foretold: true });
const state = buildGameState({ objects: buildObjectMap(exiled), exile_links: [] });
expect(isFaceDownExileCardVisibleToViewer(state, exiled, 0)).toBe(true);
});
it("uses only the engine-projected display bit", () => {
const visible = faceDownObject({ display_visible_to_viewer: true });
const hidden = faceDownObject({ display_visible_to_viewer: false, foretold: true });
const state = buildGameState({ objects: buildObjectMap(visible, hidden) });

it("is false for an opponent of a foretold card's owner", () => {
const exiled = faceDownObject({ owner: 0, controller: 0, foretold: true });
const state = buildGameState({ objects: buildObjectMap(exiled), exile_links: [] });
expect(isFaceDownExileCardVisibleToViewer(state, exiled, 1)).toBe(false);
expect(isFaceDownExileCardVisibleToViewer(state, visible, 1)).toBe(true);
expect(isFaceDownExileCardVisibleToViewer(state, hidden, 0)).toBe(false);
});
});
Loading
Loading