From db01ce2c61cfbfd728ffba12a089d5aa0d99a6f7 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 2 Aug 2026 12:06:59 -0700 Subject: [PATCH 1/2] fix: preserve native library visibility --- .../components/chrome/DebugLibraryViewer.tsx | 6 +- .../components/chrome/DebugPlayerActions.tsx | 7 +- .../src/components/modal/CardChoiceModal.tsx | 25 +++- .../__tests__/SearchChoiceModal.test.tsx | 72 +++++++++++ crates/engine/src/game/visibility.rs | 116 ++++++++++++++---- 5 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 client/src/components/modal/__tests__/SearchChoiceModal.test.tsx diff --git a/client/src/components/chrome/DebugLibraryViewer.tsx b/client/src/components/chrome/DebugLibraryViewer.tsx index 9295b91704..3e442d6358 100644 --- a/client/src/components/chrome/DebugLibraryViewer.tsx +++ b/client/src/components/chrome/DebugLibraryViewer.tsx @@ -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); diff --git a/client/src/components/chrome/DebugPlayerActions.tsx b/client/src/components/chrome/DebugPlayerActions.tsx index 8708082112..515fd509d2 100644 --- a/client/src/components/chrome/DebugPlayerActions.tsx +++ b/client/src/components/chrome/DebugPlayerActions.tsx @@ -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(); diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx index ec24f65838..4abf0a44fe 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -476,8 +476,19 @@ 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?.[String(data.player)]?.looked_at, + ); const hoverProps = useInspectHoverProps(); const [selectedSet, setSelectedSet] = useState>(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; @@ -520,23 +531,27 @@ function SearchModal({ data }: { data: SearchChoice["data"] }) { footer={} > - {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 ( toggleSelect(id)} + whileHover={isSelectable ? { scale: 1.05, y: -6 } : undefined} + onClick={() => isSelectable && toggleSelect(id)} {...hoverProps(id)} > ({ + 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(); + + expect(screen.getByLabelText(/Eligible Card/)).toBeInTheDocument(); + const ineligibleCard = screen.getByLabelText(/Ineligible Card/); + expect(ineligibleCard.closest("button")).toBeDisabled(); + }); +}); diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index a1fa2ab8a1..5aee09a6b7 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -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 = 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 = if let WaitingFor::SearchChoice { player, ref cards, .. @@ -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 @@ -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) @@ -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) @@ -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, @@ -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( @@ -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( @@ -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. From 5b89d91d388e6bd8e70c7271b795161b1a4127fd Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 2 Aug 2026 12:14:24 -0700 Subject: [PATCH 2/2] fix: type search visibility lookup --- client/src/components/modal/CardChoiceModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx index 4abf0a44fe..160c915b8e 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -19,6 +19,7 @@ import type { OutsideGameChoiceEntry, OutsideGameSelection, PlayerId, + SerializedPlayerIdKey, TargetFilter, WaitingFor, Zone, @@ -477,7 +478,10 @@ function SearchModal({ data }: { data: SearchChoice["data"] }) { const dispatch = useGameDispatch(); const objects = useGameStore((s) => s.gameState?.objects); const lookedAt = useGameStore( - (s) => s.gameState?.active_library_searches?.[String(data.player)]?.looked_at, + (s) => + s.gameState?.active_library_searches?.[ + data.player.toString() as SerializedPlayerIdKey + ]?.looked_at, ); const hoverProps = useInspectHoverProps(); const [selectedSet, setSelectedSet] = useState>(new Set());