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
6 changes: 3 additions & 3 deletions client/src/components/chrome/DebugLibraryViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { useUiStore } from "../../stores/uiStore";
*
* The cards are shown in a STABLE RANDOMIZED order rather than their true
* library order. The engine deliberately leaves the on-wire `library` Vec order
* untouched (sandbox debug exposes card *names* but must not leak *draw order*,
* per `visibility.rs`), so this view shuffles the display once per open. Moving
* a card out simply removes it from its slot — the rest keep their positions.
* untouched (debug exposes card *names* but must not leak *draw order*, per
* `visibility.rs`), so this view shuffles the display once per open. Moving a
* card out simply removes it from its slot — the rest keep their positions.
*/
export function DebugLibraryViewer() {
const viewer = useUiStore((s) => s.debugLibraryViewer);
Expand Down
7 changes: 4 additions & 3 deletions client/src/components/chrome/DebugPlayerActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,10 @@ function ModifyEnergyForm({ onDispatch }: Props) {
}

// Opens the debug library browser for the local (perspective) player. The
// engine only exposes the viewer's OWN library names in sandbox debug
// (`visibility.rs`), so this is intentionally scoped to the perspective seat
// rather than offering a player picker that would render opponent backs.
// engine only exposes the viewer's OWN library names while that viewer has an
// active debug capability (`visibility.rs`), so this is intentionally scoped
// to the perspective seat rather than offering a player picker that would
// render opponent backs.
function BrowseLibraryForm() {
const openDebugLibraryViewer = useUiStore((s) => s.openDebugLibraryViewer);
const perspectivePlayerId = usePerspectivePlayerId();
Expand Down
29 changes: 24 additions & 5 deletions client/src/components/modal/CardChoiceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
OutsideGameChoiceEntry,
OutsideGameSelection,
PlayerId,
SerializedPlayerIdKey,
TargetFilter,
WaitingFor,
Zone,
Expand Down Expand Up @@ -476,8 +477,22 @@ function SearchModal({ data }: { data: SearchChoice["data"] }) {
const { t } = useTranslation("game");
const dispatch = useGameDispatch();
const objects = useGameStore((s) => s.gameState?.objects);
const lookedAt = useGameStore(
(s) =>
s.gameState?.active_library_searches?.[
data.player.toString() as SerializedPlayerIdKey
]?.looked_at,
);
const hoverProps = useInspectHoverProps();
const [selectedSet, setSelectedSet] = useState<Set<ObjectId>>(new Set());
// The engine records every card the searching player looked at. `cards`
// remains the legal-selection subset; rendering the full engine-provided
// look set lets a library-search modal show the remaining library while
// keeping non-matching cards unselectable.
const displayedCards = lookedAt
? Array.from(new Set([...lookedAt.map(([, , identity]) => identity.object_id), ...data.cards]))
: data.cards;
const selectableCards = new Set(data.cards);
const countValid = searchChoiceAllowsPartialFind(data)
? selectedSet.size <= data.count
: selectedSet.size === data.count;
Expand Down Expand Up @@ -520,23 +535,27 @@ function SearchModal({ data }: { data: SearchChoice["data"] }) {
footer={<ConfirmButton onClick={handleConfirm} disabled={!countValid} />}
>
<ScrollableCardStrip>
{data.cards.map((id, index) => {
{displayedCards.map((id, index) => {
const obj = objects[id];
if (!obj) return null;
const isSelected = selectedSet.has(id);
const isSelectable = selectableCards.has(id);
return (
<motion.button
key={id}
disabled={!isSelectable}
className={`relative shrink-0 rounded-lg transition ${
isSelected
? "z-10 ring-2 ring-emerald-400/80"
: "hover:shadow-[0_0_16px_rgba(200,200,255,0.3)]"
: isSelectable
? "hover:shadow-[0_0_16px_rgba(200,200,255,0.3)]"
: "cursor-not-allowed opacity-40"
}`}
initial={{ opacity: 0, y: 60, scale: 0.85 }}
animate={{ opacity: isSelected ? 1 : 0.7, y: 0, scale: 1 }}
animate={{ opacity: isSelected ? 1 : isSelectable ? 0.7 : 0.4, y: 0, scale: 1 }}
transition={{ delay: 0.1 + index * 0.08, duration: 0.35 }}
whileHover={{ scale: 1.05, y: -6 }}
onClick={() => toggleSelect(id)}
whileHover={isSelectable ? { scale: 1.05, y: -6 } : undefined}
onClick={() => isSelectable && toggleSelect(id)}
{...hoverProps(id)}
>
<CardImage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { GameObject, WaitingFor } from "../../../adapter/types.ts";
import { useGameStore } from "../../../stores/gameStore.ts";
import { useMultiplayerStore } from "../../../stores/multiplayerStore.ts";
import { buildGameObject } from "../../../test/factories/gameObjectFactory.ts";
import { buildGameState, buildPlayer } from "../../../test/factories/gameStateFactory.ts";
import { CardChoiceModal } from "../CardChoiceModal.tsx";

const dispatchMock = vi.fn();

vi.mock("../../../hooks/useGameDispatch.ts", () => ({
useGameDispatch: () => dispatchMock,
}));

function makeObject(id: number, name: string): GameObject {
return buildGameObject({
id,
card_id: id,
zone: "Library",
name,
card_types: { supertypes: [], core_types: ["Instant"], subtypes: [] },
mana_cost: { type: "Cost", shards: [], generic: 1 },
timestamp: id,
});
}

describe("SearchChoice modal", () => {
beforeEach(() => {
dispatchMock.mockClear();
useMultiplayerStore.setState({ activePlayerId: 0 });
});

afterEach(() => {
cleanup();
});

it("shows every card the engine exposed during a library search", () => {
const waitingFor: WaitingFor = {
type: "SearchChoice",
data: { player: 0, cards: [42], count: 1 },
};
const state = buildGameState({
players: [buildPlayer({ id: 0, library: [42, 43] }), buildPlayer({ id: 1 })],
objects: {
42: makeObject(42, "Eligible Card"),
43: makeObject(43, "Ineligible Card"),
},
waiting_for: waitingFor,
active_library_searches: {
0: {
searcher: 0,
searched_zone_owner: 0,
effective_library_owner: 0,
learned_audience: [0],
looked_at: [
[0, "Library", { object_id: 42, incarnation: 0 }],
[0, "Library", { object_id: 43, incarnation: 0 }],
],
},
},
});
useGameStore.setState({ gameMode: "online", gameState: state, waitingFor });

render(<CardChoiceModal />);

expect(screen.getByLabelText(/Eligible Card/)).toBeInTheDocument();
const ineligibleCard = screen.getByLabelText(/Ineligible Card/);
expect(ineligibleCard.closest("button")).toBeDisabled();
});
});
116 changes: 89 additions & 27 deletions crates/engine/src/game/visibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,22 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState
HashSet::new()
};

// CR 701.22a: Scry instructs the player to look at the top N cards of
// their library before ordering them. Those cards remain in the library,
// so explicitly preserve their identities for the player making the choice.
let scry_visible: HashSet<ObjectId> = if let WaitingFor::ScryChoice {
player, ref cards, ..
} = filtered.waiting_for
{
if can_view_private_for_player(player) {
cards.iter().copied().collect()
} else {
HashSet::new()
}
} else {
HashSet::new()
};

let search_visible: HashSet<ObjectId> =
if let WaitingFor::SearchChoice {
player, ref cards, ..
Expand Down Expand Up @@ -424,17 +440,16 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState
HashSet::new()
};

// Sandbox debug exposure: a viewer who holds debug permission in a sandbox
// game (CR is silent; this is an out-of-game capability) sees the names of
// cards in their *own* library, so the debug "move card from library to
// hand" picker can identify a specific card. Opponents' libraries remain
// hidden — sandbox is shared, but reading an opponent's deck is not. The
// FE's debug picker alphabetizes within each zone bucket, so exposing names
// does not leak draw order. The actual `library` Vec order on the wire is
// left untouched (preserving simulate-mode draw semantics) but is never
// surfaced as draw order anywhere the viewer can observe it.
let sandbox_self_library_visible =
state.format_config.allow_debug_actions && state.debug_permitted.contains(&viewer);
// Debug exposure: a viewer with an active debug capability (CR is silent;
// this is an out-of-game capability) sees the names of cards in their own
// library, so the debug "move card from library to hand" picker can identify
// a specific card. This includes native single-user games, whose debug
// capability is intentionally independent of the multiplayer sandbox
// format flag. Opponents' libraries remain hidden. The FE alphabetizes the
// picker within each zone bucket, so name exposure does not leak draw order.
// The actual `library` Vec order on the wire is left untouched (preserving
// simulate-mode draw semantics) but is never surfaced as draw order.
let debug_self_library_visible = state.debug_mode && state.debug_permitted.contains(&viewer);
// CR 701.20e + CR 400.2: "looking at a card ... is shown only to the
// specified player." A player with a continuous "you may look at the top
// card of your library" permission (MayLookAtTopOfLibrary — Vizier of the
Expand All @@ -461,6 +476,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState
let owner = state.objects.get(&obj_id).map(|o| o.owner);
let visible = manifest_dread_visible.contains(&obj_id)
|| dig_visible.contains(&obj_id)
|| scry_visible.contains(&obj_id)
|| private_look_visible.contains(&obj_id)
|| search_visible.contains(&obj_id)
|| effect_zone_library_visible.contains(&obj_id)
Expand All @@ -476,7 +492,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState
// CR 701.20e: own (or controlled-turn) library top under a
// MayLookAtTopOfLibrary permission — see `look_top_visible` above.
|| look_top_visible.contains(&obj_id)
|| (sandbox_self_library_visible && owner == Some(viewer));
|| (debug_self_library_visible && owner == Some(viewer));
if !visible
&& !effect_zone_hand_cards.contains(&obj_id)
&& !drawn_choice_hand_cards.contains(&obj_id)
Expand Down Expand Up @@ -828,6 +844,18 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState
}
}

if let WaitingFor::ScryChoice {
player, ref cards, ..
} = state.waiting_for
{
if !can_view_private_for_player(player) {
filtered.waiting_for = WaitingFor::ScryChoice {
player,
cards: cards.iter().map(|_| ObjectId(0)).collect(),
};
}
}

if let WaitingFor::LearnChoice {
player,
ref hand_cards,
Expand Down Expand Up @@ -3049,15 +3077,12 @@ mod tests {
);
}

/// Sandbox debug exposure: a viewer with debug permission in a sandbox
/// game sees their own library card names (so the debug "move from
/// library to hand" picker can identify a specific card). Opponents'
/// libraries stay hidden — sandbox is a shared playground for your own
/// materials, not an opponent-deck-leak. The FE alphabetizes the picker
/// within each zone, so name exposure alone leaks no draw order.
/// Debug capability exposure lets a viewer identify cards in their own
/// library for debug actions, without exposing an opponent's library.
#[test]
fn sandbox_debug_permitted_sees_own_library_but_not_opponent_library() {
let mut state = GameState::new(FormatConfig::standard().with_sandbox(), 2, 42);
fn debug_permitted_sees_own_library_but_not_opponent_library() {
let mut state = GameState::new(FormatConfig::standard(), 2, 42);
state.debug_mode = true;
state.debug_permitted.insert(PlayerId(0));
state.debug_permitted.insert(PlayerId(1));
let own = create_object(
Expand All @@ -3079,20 +3104,19 @@ mod tests {
assert_eq!(
filtered.objects.get(&own).map(|obj| obj.name.as_str()),
Some("My Library Card"),
"viewer must see their own library names in sandbox+permitted"
"viewer must see their own library names with debug capability"
);
assert_eq!(
filtered.objects.get(&opp).map(|obj| obj.name.as_str()),
Some("Hidden Card"),
"opponent's library stays hidden even in sandbox"
"opponent's library stays hidden during debug actions"
);
}

/// Without the sandbox capability, debug permission alone must not
/// expose the library — defense in depth against accidentally leaving
/// `debug_permitted` populated in a non-sandbox game.
/// Permission alone is not a debug capability. This prevents a stale
/// permission set from exposing a library after debug mode is disabled.
#[test]
fn non_sandbox_keeps_own_library_hidden_even_when_debug_permitted() {
fn debug_permission_without_debug_mode_keeps_own_library_hidden() {
let mut state = GameState::new(FormatConfig::standard(), 2, 42);
state.debug_permitted.insert(PlayerId(0));
let own = create_object(
Expand All @@ -3107,8 +3131,46 @@ mod tests {
assert_eq!(
filtered.objects.get(&own).map(|obj| obj.name.as_str()),
Some("Hidden Card"),
"non-sandbox must keep library hidden regardless of debug_permitted"
"a stale debug permission must not reveal a library"
);
}

#[test]
fn scry_choice_is_visible_to_its_player_but_not_an_opponent() {
let mut state = GameState::new_two_player(42);
let card = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Scryed Card".to_string(),
Zone::Library,
);
state.waiting_for = WaitingFor::ScryChoice {
player: PlayerId(0),
cards: vec![card],
};

let searcher_view = filter_state_for_viewer(&state, PlayerId(0));
assert_eq!(
searcher_view
.objects
.get(&card)
.map(|obj| obj.name.as_str()),
Some("Scryed Card")
);

let opponent_view = filter_state_for_viewer(&state, PlayerId(1));
assert_eq!(
opponent_view
.objects
.get(&card)
.map(|obj| obj.name.as_str()),
Some("Hidden Card")
);
assert!(matches!(
opponent_view.waiting_for,
WaitingFor::ScryChoice { cards, .. } if cards == vec![ObjectId(0)]
));
}

/// CR 400.7 + CR 122.2: A card that was publicly revealed in hand (e.g.
Expand Down
Loading