diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index d4cbf49626..4e19b7e040 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1786,35 +1786,68 @@ fn is_visible_revealed_card(state: &GameState, viewer: PlayerId, obj_id: ObjectI }) } +/// Removes printed-card identity from a viewer projection while retaining the +/// public object identity and runtime state needed to render the game. +fn redact_printed_identity(obj: &mut crate::game::game_object::GameObject) { + obj.card_id = CardId(0); + obj.base_name = HIDDEN_CARD_NAME.to_string(); + obj.token_rules_text = None; + obj.attraction_lights.clear(); + obj.token_image_ref = None; + obj.source_related_token_ids.clear(); + obj.spellbook.clear(); + obj.parse_warnings.clear(); + obj.back_face = None; + obj.specialize_faces = None; + obj.specialized_color = None; + obj.cleave_variant = None; + obj.modal = None; + obj.additional_cost = None; + obj.strive_cost = None; + obj.casting_restrictions.clear(); + obj.casting_options.clear(); + obj.casting_permissions.clear(); + obj.unimplemented_mechanics.clear(); + + obj.base_power = None; + obj.base_toughness = None; + obj.base_loyalty = None; + obj.base_printed_loyalty = None; + obj.base_defense = None; + obj.base_card_types = Default::default(); + obj.base_mana_cost = Default::default(); + obj.base_keywords.clear(); + Arc::make_mut(&mut obj.base_abilities).clear(); + Arc::make_mut(&mut obj.base_trigger_definitions).clear(); + Arc::make_mut(&mut obj.base_replacement_definitions).clear(); + Arc::make_mut(&mut obj.base_static_definitions).clear(); + obj.base_color.clear(); + obj.base_printed_ref = None; + obj.room_unlocks = None; +} + fn hide_card(state: &mut GameState, obj_id: ObjectId) { if let Some(obj) = state.objects.get_mut(&obj_id) { + // CR 400.2: library and hand are hidden zones — a viewer without + // look-permission may not see the card's face or any printed-card + // metadata that identifies it. obj.face_down = true; obj.name = HIDDEN_CARD_NAME.to_string(); + redact_printed_identity(obj); Arc::make_mut(&mut obj.abilities).clear(); obj.keywords.clear(); - obj.base_keywords.clear(); obj.power = None; obj.toughness = None; obj.loyalty = None; + obj.printed_loyalty = None; + obj.defense = None; + obj.card_types = Default::default(); + obj.mana_cost = Default::default(); obj.color.clear(); - obj.base_color.clear(); obj.trigger_definitions.clear(); obj.replacement_definitions.clear(); obj.static_definitions.clear(); - obj.casting_permissions.clear(); obj.printed_ref = None; - obj.base_printed_ref = None; - obj.back_face = None; - obj.token_image_ref = None; - obj.source_related_token_ids.clear(); - // CR 400.2: library and hand are hidden zones — a viewer without - // look-permission may not see the card's face. `parse_warnings` is - // parser evidence derived from that face's printed text (a - // `SwallowedClause` carries the rules text verbatim), and only 891 of - // 35,657 card faces carry a non-empty vector, so its presence and - // contents both fingerprint the card. Redact it with the rest of the - // printed identity. - obj.parse_warnings.clear(); obj.foretold = false; } } @@ -1840,10 +1873,8 @@ fn reveal_face_down_identity_to_controller(obj: &mut crate::game::game_object::G fn redact_face_down_identity_from_observer(obj: &mut crate::game::game_object::GameObject) { obj.name = HIDDEN_CARD_NAME.to_string(); - obj.base_name = HIDDEN_CARD_NAME.to_string(); + redact_printed_identity(obj); obj.printed_ref = None; - obj.base_printed_ref = None; - obj.back_face = None; // CR 708.5 + CR 708.2: a face-down permanent has no name and no abilities, // and no player but its controller may look at the card underneath. // `parse_warnings` survives the face-down transformation on the @@ -1911,7 +1942,7 @@ mod tests { ManaAbilityResume, MayTriggerAutoChoiceKey, MayTriggerOrigin, PendingBeginGameAbility, PendingCast, PendingCostMoveCompletion, PendingCostMoveResume, PendingManaAbility, PendingScopedLibrarySearch, PendingSearchFoundBatch, PreparedScopedLibrarySearchChoice, - TargetEffectDetail, + RoomUnlockState, TargetEffectDetail, }; use crate::types::identifiers::CardId; use crate::types::mana::ManaCost; @@ -2766,6 +2797,102 @@ mod tests { assert!(hidden.back_face.is_none()); } + /// CR 400.2: a non-owner's hidden-zone projection must not carry printed + /// identity through baseline characteristics or card metadata. The owner + /// arm proves each sentinel is present before the observer projection. + #[test] + fn hidden_hand_card_redacts_printed_identity_metadata_from_opponent() { + let mut state = GameState::new_two_player(42); + let card_id = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Secret Printed Card".to_string(), + Zone::Hand, + ); + { + let obj = state.objects.get_mut(&card_id).unwrap(); + let card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec!["Secret Type".to_string()], + }; + obj.base_name = "Secret Base Name".to_string(); + obj.token_rules_text = Some("Secret token rules".to_string()); + obj.spellbook = vec!["Secret Spellbook Card".to_string()]; + obj.unimplemented_mechanics = vec!["Secret mechanic".to_string()]; + obj.power = Some(7); + obj.toughness = Some(9); + obj.base_power = Some(7); + obj.base_toughness = Some(9); + obj.card_types = card_types.clone(); + obj.base_card_types = card_types; + obj.mana_cost = ManaCost::generic(7); + obj.base_mana_cost = ManaCost::generic(7); + obj.room_unlocks = Some(RoomUnlockState { + left_unlocked: true, + right_unlocked: false, + }); + } + + let owner_view = filter_state_for_viewer(&state, PlayerId(1)); + let owned = owner_view.objects.get(&card_id).unwrap(); + assert_eq!(owned.base_name, "Secret Base Name"); + assert_eq!(owned.spellbook, vec!["Secret Spellbook Card".to_string()]); + assert_eq!( + owned.token_rules_text.as_deref(), + Some("Secret token rules") + ); + assert_eq!( + owned.unimplemented_mechanics, + vec!["Secret mechanic".to_string()] + ); + assert_eq!(owned.base_power, Some(7)); + assert_eq!(owned.base_toughness, Some(9)); + assert_ne!(owned.base_card_types, CardType::default()); + assert_eq!(owned.base_mana_cost, ManaCost::generic(7)); + assert_eq!( + owned.room_unlocks, + Some(RoomUnlockState { + left_unlocked: true, + right_unlocked: false, + }) + ); + + let opponent_view = filter_state_for_viewer(&state, PlayerId(0)); + let hidden = opponent_view.objects.get(&card_id).unwrap(); + assert_eq!(hidden.name, HIDDEN_CARD_NAME); + assert_eq!(hidden.card_id, CardId(0)); + assert_eq!(hidden.base_name, HIDDEN_CARD_NAME); + assert!(hidden.token_rules_text.is_none()); + assert!(hidden.spellbook.is_empty()); + assert!(hidden.unimplemented_mechanics.is_empty()); + assert_eq!(hidden.power, None); + assert_eq!(hidden.toughness, None); + assert_eq!(hidden.base_power, None); + assert_eq!(hidden.base_toughness, None); + assert_eq!(hidden.card_types, CardType::default()); + assert_eq!(hidden.base_card_types, CardType::default()); + assert_eq!(hidden.mana_cost, ManaCost::default()); + assert_eq!(hidden.base_mana_cost, ManaCost::default()); + assert!(hidden.room_unlocks.is_none()); + + let serialized = serde_json::to_string(hidden).unwrap(); + for secret in [ + "Secret Printed Card", + "Secret Base Name", + "Secret token rules", + "Secret Spellbook Card", + "Secret mechanic", + "Secret Type", + ] { + assert!( + !serialized.contains(secret), + "hidden card's serialized payload must not reveal {secret:?}" + ); + } + } + /// CR 400.2: a hand is a hidden zone. `parse_warnings` is derived from the /// hidden face's printed text — an `IgnoredRemainder`/`SwallowedClause` /// payload quotes that text verbatim, and `skip_serializing_if` makes the