weiss_core/env/
visibility.rs

1use super::{GameEnv, VisibilityContext};
2use crate::effects::*;
3use crate::encode::*;
4use crate::events::*;
5use crate::legal::*;
6use crate::replay::*;
7use crate::state::*;
8use crate::visibility_policy::{
9    hide_target_zone_for_viewer, hide_zone_for_viewer, zone_identity_visibility,
10    ZoneIdentityVisibility,
11};
12
13impl GameEnv {
14    pub(super) fn reveal_card(
15        &mut self,
16        player: u8,
17        card: &CardInstance,
18        reason: RevealReason,
19        audience: RevealAudience,
20    ) {
21        let mut viewers = [0u8; 2];
22        let mut count = 0usize;
23        match audience {
24            RevealAudience::Public | RevealAudience::BothPlayers => {
25                viewers[0] = 0;
26                viewers[1] = 1;
27                count = 2;
28            }
29            RevealAudience::OwnerOnly => {
30                viewers[0] = card.owner;
31                count = 1;
32            }
33            RevealAudience::ControllerOnly => {
34                viewers[0] = card.controller;
35                count = 1;
36            }
37            RevealAudience::ReplayOnly => {}
38        }
39        for &viewer in viewers[..count].iter() {
40            if let Some(history) = self.state.reveal_history.get_mut(viewer as usize) {
41                history.push(card.id);
42            }
43        }
44        if self.curriculum.enable_visibility_policies && count > 0 {
45            self.mark_instance_revealed(&viewers[..count], card.instance_id);
46        }
47        self.log_event(Event::Reveal {
48            player,
49            card: card.id,
50            reason,
51            audience,
52        });
53    }
54
55    pub(super) fn reveal_cards(
56        &mut self,
57        player: u8,
58        cards: &[CardInstance],
59        reason: RevealReason,
60        audience: RevealAudience,
61    ) -> Vec<CardInstance> {
62        for card in cards {
63            self.reveal_card(player, card, reason, audience);
64        }
65        cards.to_vec()
66    }
67
68    pub(super) fn log_event(&mut self, event: Event) {
69        if self.recording {
70            let ctx = self.replay_visibility_context();
71            self.canonical_events.push(event.clone());
72            let replay_event = self.sanitize_event_for_viewer(&event, ctx);
73            self.replay_events.push(replay_event);
74        }
75        if self.debug_event_ring.is_some() {
76            let mut sanitized = [None, None];
77            for viewer in 0..2u8 {
78                let ctx = self.debug_visibility_context(viewer);
79                sanitized[viewer as usize] = Some(self.sanitize_event_for_viewer(&event, ctx));
80            }
81            if let Some(rings) = self.debug_event_ring.as_mut() {
82                for viewer in 0..2u8 {
83                    if let Some(entry) = sanitized[viewer as usize].take() {
84                        rings[viewer as usize].push(entry);
85                    }
86                }
87            }
88        }
89    }
90
91    pub(super) fn log_action(&mut self, actor: u8, action: ActionDesc) {
92        let ctx = self.replay_visibility_context();
93        let logged = self.sanitize_action_for_viewer(&action, actor, ctx);
94        self.replay_actions.push(logged);
95    }
96
97    pub(super) fn sanitize_action_for_viewer(
98        &self,
99        action: &ActionDesc,
100        actor: u8,
101        ctx: VisibilityContext,
102    ) -> ActionDesc {
103        const UNKNOWN_INDEX: u8 = u8::MAX;
104        if !ctx.is_public() {
105            return action.clone();
106        }
107        let hide_for_viewer = match ctx.viewer {
108            Some(viewer) => viewer != actor,
109            None => true,
110        };
111        if !hide_for_viewer {
112            return action.clone();
113        }
114        match action {
115            ActionDesc::MulliganSelect { .. } => ActionDesc::MulliganSelect {
116                hand_index: UNKNOWN_INDEX,
117            },
118            ActionDesc::Clock { .. } => ActionDesc::Clock {
119                hand_index: UNKNOWN_INDEX,
120            },
121            ActionDesc::MainPlayCharacter { stage_slot, .. } => ActionDesc::MainPlayCharacter {
122                hand_index: UNKNOWN_INDEX,
123                stage_slot: *stage_slot,
124            },
125            ActionDesc::MainPlayEvent { .. } => ActionDesc::MainPlayEvent {
126                hand_index: UNKNOWN_INDEX,
127            },
128            ActionDesc::ClimaxPlay { .. } => ActionDesc::ClimaxPlay {
129                hand_index: UNKNOWN_INDEX,
130            },
131            ActionDesc::CounterPlay { .. } => ActionDesc::CounterPlay {
132                hand_index: UNKNOWN_INDEX,
133            },
134            ActionDesc::ChoiceSelect { .. } => ActionDesc::ChoiceSelect {
135                index: UNKNOWN_INDEX,
136            },
137            _ => action.clone(),
138        }
139    }
140
141    pub(super) fn replay_visibility_context(&self) -> VisibilityContext {
142        let policies_enabled = self.curriculum.enable_visibility_policies;
143        let mode = self.config.observation_visibility;
144        let viewer = None;
145        VisibilityContext {
146            viewer,
147            mode,
148            policies_enabled,
149        }
150    }
151
152    pub(super) fn debug_visibility_context(&self, viewer: u8) -> VisibilityContext {
153        VisibilityContext {
154            viewer: Some(viewer),
155            mode: self.config.observation_visibility,
156            policies_enabled: true,
157        }
158    }
159
160    pub(super) fn zone_hidden_for_viewer(
161        &self,
162        ctx: VisibilityContext,
163        owner: u8,
164        zone: Zone,
165    ) -> bool {
166        if !ctx.is_public() {
167            return false;
168        }
169        hide_zone_for_viewer(ctx.mode, ctx.viewer, owner, zone, &self.curriculum)
170    }
171
172    pub(super) fn instance_revealed_to_viewer(
173        &self,
174        ctx: VisibilityContext,
175        instance_id: CardInstanceId,
176    ) -> bool {
177        if instance_id == 0 {
178            return false;
179        }
180        match ctx.viewer {
181            Some(viewer) => self.revealed_to_viewer[viewer as usize].contains(&instance_id),
182            None => {
183                self.revealed_to_viewer[0].contains(&instance_id)
184                    && self.revealed_to_viewer[1].contains(&instance_id)
185            }
186        }
187    }
188
189    pub(super) fn mark_instance_revealed(&mut self, viewers: &[u8], instance_id: CardInstanceId) {
190        if instance_id == 0 {
191            return;
192        }
193        for &viewer in viewers {
194            if let Some(set) = self.revealed_to_viewer.get_mut(viewer as usize) {
195                set.insert(instance_id);
196            }
197        }
198    }
199
200    pub(super) fn forget_instance_revealed(&mut self, instance_id: CardInstanceId) {
201        if instance_id == 0 {
202            return;
203        }
204        for set in &mut self.revealed_to_viewer {
205            set.remove(&instance_id);
206        }
207    }
208
209    pub(super) fn on_card_enter_zone(&mut self, card: &CardInstance, zone: Zone) {
210        if !self.curriculum.enable_visibility_policies {
211            return;
212        }
213        match zone_identity_visibility(zone, &self.curriculum) {
214            ZoneIdentityVisibility::Public => {
215                self.mark_instance_revealed(&[0, 1], card.instance_id);
216            }
217            ZoneIdentityVisibility::OwnerOnly => {
218                self.forget_instance_revealed(card.instance_id);
219            }
220        }
221    }
222
223    pub(super) fn target_hidden_for_viewer(
224        &self,
225        ctx: VisibilityContext,
226        owner: u8,
227        zone: TargetZone,
228    ) -> bool {
229        if !ctx.is_public() {
230            return false;
231        }
232        hide_target_zone_for_viewer(ctx.mode, ctx.viewer, owner, zone, &self.curriculum)
233    }
234
235    pub(super) fn reveal_visible_to_viewer(
236        &self,
237        ctx: VisibilityContext,
238        owner: u8,
239        audience: RevealAudience,
240    ) -> bool {
241        if !ctx.is_public() {
242            return true;
243        }
244        match audience {
245            RevealAudience::Public | RevealAudience::BothPlayers => true,
246            RevealAudience::OwnerOnly | RevealAudience::ControllerOnly => {
247                ctx.viewer.map(|viewer| viewer == owner).unwrap_or(false)
248            }
249            RevealAudience::ReplayOnly => false,
250        }
251    }
252
253    pub(super) fn sanitize_target_ref(
254        &self,
255        ctx: VisibilityContext,
256        target: TargetRef,
257    ) -> TargetRef {
258        if !self.target_hidden_for_viewer(ctx, target.player, target.zone) {
259            return target;
260        }
261        TargetRef {
262            player: target.player,
263            zone: target.zone,
264            index: 0,
265            card_id: 0,
266            instance_id: 0,
267        }
268    }
269
270    pub(super) fn sanitize_stack_item(
271        &self,
272        ctx: VisibilityContext,
273        item: &StackItem,
274    ) -> StackItem {
275        if !ctx.is_public() {
276            return item.clone();
277        }
278        let hide_source = match ctx.viewer {
279            Some(viewer) => viewer != item.controller,
280            None => true,
281        };
282        let source_id = if hide_source { 0 } else { item.source_id };
283        let targets = item
284            .payload
285            .targets
286            .iter()
287            .copied()
288            .map(|t| self.sanitize_target_ref(ctx, t))
289            .collect();
290        StackItem {
291            id: item.id,
292            controller: item.controller,
293            source_id,
294            effect_id: item.effect_id,
295            payload: EffectPayload {
296                spec: item.payload.spec.clone(),
297                targets,
298            },
299        }
300    }
301
302    pub(super) fn sanitize_event_for_viewer(
303        &self,
304        event: &Event,
305        ctx: VisibilityContext,
306    ) -> ReplayEvent {
307        match event {
308            Event::Draw { player, card } => {
309                let hide = self.zone_hidden_for_viewer(ctx, *player, Zone::Deck)
310                    || self.zone_hidden_for_viewer(ctx, *player, Zone::Hand);
311                let card = if hide { 0 } else { *card };
312                ReplayEvent::Draw {
313                    player: *player,
314                    card,
315                }
316            }
317            Event::Damage { player, card } => ReplayEvent::Damage {
318                player: *player,
319                card: *card,
320            },
321            Event::DamageCancel { player } => ReplayEvent::DamageCancel { player: *player },
322            Event::DamageIntent {
323                event_id,
324                source_player,
325                source_slot,
326                target,
327                amount,
328                damage_type,
329                cancelable,
330            } => ReplayEvent::DamageIntent {
331                event_id: *event_id,
332                source_player: *source_player,
333                source_slot: *source_slot,
334                target: *target,
335                amount: *amount,
336                damage_type: *damage_type,
337                cancelable: *cancelable,
338            },
339            Event::DamageModifierApplied {
340                event_id,
341                modifier,
342                before_amount,
343                after_amount,
344                before_cancelable,
345                after_cancelable,
346                before_canceled,
347                after_canceled,
348            } => ReplayEvent::DamageModifierApplied {
349                event_id: *event_id,
350                modifier: *modifier,
351                before_amount: *before_amount,
352                after_amount: *after_amount,
353                before_cancelable: *before_cancelable,
354                after_cancelable: *after_cancelable,
355                before_canceled: *before_canceled,
356                after_canceled: *after_canceled,
357            },
358            Event::DamageModified {
359                event_id,
360                target,
361                original,
362                modified,
363                canceled,
364                damage_type,
365            } => ReplayEvent::DamageModified {
366                event_id: *event_id,
367                target: *target,
368                original: *original,
369                modified: *modified,
370                canceled: *canceled,
371                damage_type: *damage_type,
372            },
373            Event::DamageCommitted {
374                event_id,
375                target,
376                card,
377                damage_type,
378            } => ReplayEvent::DamageCommitted {
379                event_id: *event_id,
380                target: *target,
381                card: *card,
382                damage_type: *damage_type,
383            },
384            Event::ReversalCommitted {
385                player,
386                slot,
387                cause_damage_event,
388            } => ReplayEvent::ReversalCommitted {
389                player: *player,
390                slot: *slot,
391                cause_damage_event: *cause_damage_event,
392            },
393            Event::Reveal {
394                player,
395                card,
396                reason,
397                audience,
398            } => {
399                let visible = self.reveal_visible_to_viewer(ctx, *player, *audience);
400                ReplayEvent::Reveal {
401                    player: *player,
402                    card: if visible { *card } else { 0 },
403                    reason: *reason,
404                    audience: *audience,
405                }
406            }
407            Event::TriggerQueued {
408                trigger_id,
409                group_id,
410                player,
411                source,
412                effect,
413            } => ReplayEvent::TriggerQueued {
414                trigger_id: *trigger_id,
415                group_id: *group_id,
416                player: *player,
417                source: *source,
418                effect: *effect,
419            },
420            Event::TriggerGrouped {
421                group_id,
422                trigger_ids,
423            } => ReplayEvent::TriggerGrouped {
424                group_id: *group_id,
425                trigger_ids: trigger_ids.clone(),
426            },
427            Event::TriggerResolved {
428                trigger_id,
429                player,
430                effect,
431            } => ReplayEvent::TriggerResolved {
432                trigger_id: *trigger_id,
433                player: *player,
434                effect: *effect,
435            },
436            Event::TriggerCanceled {
437                trigger_id,
438                player,
439                reason,
440            } => ReplayEvent::TriggerCanceled {
441                trigger_id: *trigger_id,
442                player: *player,
443                reason: *reason,
444            },
445            Event::TimingWindowEntered { window, player } => ReplayEvent::TimingWindowEntered {
446                window: *window,
447                player: *player,
448            },
449            Event::PriorityGranted { window, player } => ReplayEvent::PriorityGranted {
450                window: *window,
451                player: *player,
452            },
453            Event::PriorityPassed {
454                player,
455                window,
456                pass_count,
457            } => ReplayEvent::PriorityPassed {
458                player: *player,
459                window: *window,
460                pass_count: *pass_count,
461            },
462            Event::StackGroupPresented {
463                group_id,
464                controller,
465                items,
466            } => ReplayEvent::StackGroupPresented {
467                group_id: *group_id,
468                controller: *controller,
469                items: items
470                    .iter()
471                    .map(|item| self.sanitize_stack_item(ctx, item))
472                    .collect(),
473            },
474            Event::StackOrderChosen {
475                group_id,
476                controller,
477                stack_id,
478            } => ReplayEvent::StackOrderChosen {
479                group_id: *group_id,
480                controller: *controller,
481                stack_id: *stack_id,
482            },
483            Event::StackPushed { item } => ReplayEvent::StackPushed {
484                item: self.sanitize_stack_item(ctx, item),
485            },
486            Event::StackResolved { item } => ReplayEvent::StackResolved {
487                item: self.sanitize_stack_item(ctx, item),
488            },
489            Event::AutoResolveCapExceeded {
490                cap,
491                stack_len,
492                window,
493            } => ReplayEvent::AutoResolveCapExceeded {
494                cap: *cap,
495                stack_len: *stack_len,
496                window: *window,
497            },
498            Event::WindowAdvanced { from, to } => ReplayEvent::WindowAdvanced {
499                from: *from,
500                to: *to,
501            },
502            Event::ChoicePresented {
503                choice_id,
504                player,
505                reason,
506                options,
507                total_candidates,
508                page_start,
509            } => {
510                let summaries = self.summarize_choice_options_for_event(
511                    *reason,
512                    *player,
513                    options,
514                    *page_start,
515                    *choice_id,
516                    ctx,
517                );
518                ReplayEvent::ChoicePresented {
519                    choice_id: *choice_id,
520                    player: *player,
521                    reason: *reason,
522                    options: summaries,
523                    total_candidates: *total_candidates,
524                    page_start: *page_start,
525                }
526            }
527            Event::ChoicePageChanged {
528                choice_id,
529                player,
530                from_start,
531                to_start,
532            } => ReplayEvent::ChoicePageChanged {
533                choice_id: *choice_id,
534                player: *player,
535                from_start: *from_start,
536                to_start: *to_start,
537            },
538            Event::ChoiceMade {
539                choice_id,
540                player,
541                reason,
542                option,
543            } => {
544                let sanitized =
545                    self.sanitize_choice_option_for_event(*reason, *player, ctx, option);
546                ReplayEvent::ChoiceMade {
547                    choice_id: *choice_id,
548                    player: *player,
549                    reason: *reason,
550                    option: sanitized,
551                }
552            }
553            Event::ChoiceAutopicked {
554                choice_id,
555                player,
556                reason,
557                option,
558            } => {
559                let sanitized =
560                    self.sanitize_choice_option_for_event(*reason, *player, ctx, option);
561                ReplayEvent::ChoiceAutopicked {
562                    choice_id: *choice_id,
563                    player: *player,
564                    reason: *reason,
565                    option: sanitized,
566                }
567            }
568            Event::ChoiceSkipped {
569                choice_id,
570                player,
571                reason,
572                skip_reason,
573            } => ReplayEvent::ChoiceSkipped {
574                choice_id: *choice_id,
575                player: *player,
576                reason: *reason,
577                skip_reason: *skip_reason,
578            },
579            Event::ZoneMove {
580                player,
581                card,
582                from,
583                to,
584                from_slot,
585                to_slot,
586            } => {
587                let hide_from = self.zone_hidden_for_viewer(ctx, *player, *from);
588                let hide_to = self.zone_hidden_for_viewer(ctx, *player, *to);
589                ReplayEvent::ZoneMove {
590                    player: *player,
591                    card: if hide_from && hide_to { 0 } else { *card },
592                    from: *from,
593                    to: *to,
594                    from_slot: if hide_from { None } else { *from_slot },
595                    to_slot: if hide_to { None } else { *to_slot },
596                }
597            }
598            Event::ControlChanged {
599                card,
600                owner,
601                from_controller,
602                to_controller,
603                from_slot,
604                to_slot,
605            } => ReplayEvent::ControlChanged {
606                card: *card,
607                owner: *owner,
608                from_controller: *from_controller,
609                to_controller: *to_controller,
610                from_slot: *from_slot,
611                to_slot: *to_slot,
612            },
613            Event::ModifierAdded {
614                id,
615                source,
616                target_player,
617                target_slot,
618                target_card,
619                kind,
620                magnitude,
621                duration,
622            } => ReplayEvent::ModifierAdded {
623                id: *id,
624                source: *source,
625                target_player: *target_player,
626                target_slot: *target_slot,
627                target_card: *target_card,
628                kind: *kind,
629                magnitude: *magnitude,
630                duration: *duration,
631            },
632            Event::ModifierRemoved { id, reason } => ReplayEvent::ModifierRemoved {
633                id: *id,
634                reason: *reason,
635            },
636            Event::Concede { player } => ReplayEvent::Concede { player: *player },
637            Event::Play { player, card, slot } => ReplayEvent::Play {
638                player: *player,
639                card: *card,
640                slot: *slot,
641            },
642            Event::PlayEvent { player, card } => ReplayEvent::PlayEvent {
643                player: *player,
644                card: *card,
645            },
646            Event::PlayClimax { player, card } => ReplayEvent::PlayClimax {
647                player: *player,
648                card: *card,
649            },
650            Event::Trigger { player, icon, card } => {
651                let reveal = if self.replay_config.include_trigger_card_id {
652                    *card
653                } else {
654                    None
655                };
656                if ctx.is_public() && reveal.is_some() {
657                    // Trigger checks are public, so no additional masking.
658                }
659                ReplayEvent::Trigger {
660                    player: *player,
661                    icon: *icon,
662                    card: reveal,
663                }
664            }
665            Event::Attack { player, slot } => ReplayEvent::Attack {
666                player: *player,
667                slot: *slot,
668            },
669            Event::AttackType {
670                player,
671                attacker_slot,
672                attack_type,
673            } => ReplayEvent::AttackType {
674                player: *player,
675                attacker_slot: *attacker_slot,
676                attack_type: *attack_type,
677            },
678            Event::Counter {
679                player,
680                card,
681                power,
682            } => ReplayEvent::Counter {
683                player: *player,
684                card: *card,
685                power: *power,
686            },
687            Event::Clock { player, card } => ReplayEvent::Clock {
688                player: *player,
689                card: *card,
690            },
691            Event::Shuffle { player, zone } => ReplayEvent::Shuffle {
692                player: *player,
693                zone: *zone,
694            },
695            Event::Refresh { player } => ReplayEvent::Refresh { player: *player },
696            Event::RefreshPenalty { player, card } => ReplayEvent::RefreshPenalty {
697                player: *player,
698                card: *card,
699            },
700            Event::LevelUpChoice { player, card } => ReplayEvent::LevelUpChoice {
701                player: *player,
702                card: *card,
703            },
704            Event::Encore { player, slot, kept } => ReplayEvent::Encore {
705                player: *player,
706                slot: *slot,
707                kept: *kept,
708            },
709            Event::Stand { player } => ReplayEvent::Stand { player: *player },
710            Event::EndTurn { player } => ReplayEvent::EndTurn { player: *player },
711            Event::Terminal { winner } => ReplayEvent::Terminal { winner: *winner },
712        }
713    }
714
715    pub fn finish_episode_replay(&mut self) {
716        if !self.recording {
717            return;
718        }
719        if self.state.terminal.is_some() {
720            let need_terminal = !self
721                .replay_events
722                .iter()
723                .any(|e| matches!(e, ReplayEvent::Terminal { .. }));
724            if need_terminal {
725                let winner = match self.state.terminal {
726                    Some(TerminalResult::Win { winner }) => Some(winner),
727                    Some(TerminalResult::Draw | TerminalResult::Timeout) => None,
728                    None => None,
729                };
730                self.log_event(Event::Terminal { winner });
731            }
732        }
733        let writer = self.replay_writer.clone();
734        if let Some(writer) = writer {
735            let header = EpisodeHeader {
736                obs_version: OBS_ENCODING_VERSION,
737                action_version: ACTION_ENCODING_VERSION,
738                replay_version: REPLAY_SCHEMA_VERSION,
739                seed: self.episode_seed,
740                starting_player: self.state.turn.starting_player,
741                deck_ids: self.config.deck_ids,
742                curriculum_id: "default".to_string(),
743                config_hash: self.config.config_hash(&self.curriculum),
744                fingerprint_algo: crate::fingerprint::FINGERPRINT_ALGO.to_string(),
745                env_id: self.env_id,
746                episode_index: self.episode_index,
747            };
748            let body = EpisodeBody {
749                actions: self.replay_actions.clone(),
750                events: Some(self.replay_events.clone()),
751                steps: self.replay_steps.clone(),
752                final_state: Some(ReplayFinal {
753                    terminal: self.state.terminal,
754                    state_hash: crate::fingerprint::state_fingerprint(&self.state),
755                    decision_count: self.state.turn.decision_count,
756                    tick_count: self.state.turn.tick_count,
757                }),
758            };
759            writer.send(ReplayData { header, body });
760        }
761        self.recording = false;
762    }
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use crate::config::{
769        CurriculumConfig, EnvConfig, ErrorPolicy, ObservationVisibility, RewardConfig,
770    };
771    use crate::db::{CardColor, CardDb, CardStatic, CardType};
772    use crate::legal::ActionDesc;
773    use crate::replay::ReplayConfig;
774    use std::sync::Arc;
775
776    fn make_db() -> Arc<CardDb> {
777        let mut cards = Vec::new();
778        for id in 1..=13 {
779            cards.push(CardStatic {
780                id,
781                card_set: None,
782                card_type: CardType::Character,
783                color: CardColor::Red,
784                level: 0,
785                cost: 0,
786                power: 500,
787                soul: 1,
788                triggers: vec![],
789                traits: vec![],
790                abilities: vec![],
791                ability_defs: vec![],
792                counter_timing: false,
793                raw_text: None,
794            });
795        }
796        Arc::new(CardDb::new(cards).expect("db build"))
797    }
798
799    fn make_deck() -> Vec<u32> {
800        let mut deck = Vec::new();
801        for id in 1..=13u32 {
802            for _ in 0..4 {
803                deck.push(id);
804            }
805        }
806        deck.truncate(50);
807        deck
808    }
809
810    fn make_env() -> GameEnv {
811        let db = make_db();
812        let deck = make_deck();
813        let config = EnvConfig {
814            deck_lists: [deck.clone(), deck],
815            deck_ids: [1, 2],
816            max_decisions: 100,
817            max_ticks: 1000,
818            reward: RewardConfig::default(),
819            error_policy: ErrorPolicy::Strict,
820            observation_visibility: ObservationVisibility::Public,
821            end_condition_policy: Default::default(),
822        };
823        let curriculum = CurriculumConfig {
824            enable_visibility_policies: true,
825            ..Default::default()
826        };
827        GameEnv::new(db, config, curriculum, 9, ReplayConfig::default(), None, 0)
828    }
829
830    #[test]
831    fn sanitize_draw_hides_card_ids_in_public() {
832        let env = make_env();
833        let ctx = env.replay_visibility_context();
834        let event = Event::Draw { player: 0, card: 7 };
835        let sanitized = env.sanitize_event_for_viewer(&event, ctx);
836        match sanitized {
837            ReplayEvent::Draw { card, .. } => assert_eq!(card, 0),
838            _ => panic!("unexpected replay event"),
839        }
840    }
841
842    #[test]
843    fn sanitize_choice_option_hides_hidden_zone() {
844        let mut env = make_env();
845        let ctx = env.replay_visibility_context();
846        let option = ChoiceOptionRef {
847            card_id: 5,
848            instance_id: 123,
849            zone: ChoiceZone::Hand,
850            index: Some(0),
851            target_slot: None,
852        };
853        let sanitized = env.sanitize_choice_option_for_event(
854            ChoiceReason::PriorityActionSelect,
855            0,
856            ctx,
857            &option,
858        );
859        assert_eq!(sanitized.card_id, 0);
860        assert_eq!(sanitized.instance_id, 0);
861        assert!(sanitized.index.is_none());
862
863        env.mark_instance_revealed(&[0, 1], 123);
864        let revealed = env.sanitize_choice_option_for_event(
865            ChoiceReason::PriorityActionSelect,
866            0,
867            ctx,
868            &option,
869        );
870        assert_eq!(revealed.card_id, 5);
871        assert_eq!(revealed.instance_id, 0);
872    }
873
874    #[test]
875    fn sanitize_choice_option_strips_instance_id_in_public_replay() {
876        let env = make_env();
877        let ctx = env.replay_visibility_context();
878        let option = ChoiceOptionRef {
879            card_id: 7,
880            instance_id: 4242,
881            zone: ChoiceZone::Stage,
882            index: Some(0),
883            target_slot: None,
884        };
885        let sanitized = env.sanitize_choice_option_for_event(
886            ChoiceReason::PriorityActionSelect,
887            0,
888            ctx,
889            &option,
890        );
891        assert_eq!(sanitized.card_id, 7);
892        assert_eq!(sanitized.instance_id, 0);
893        assert_eq!(sanitized.index, Some(0));
894    }
895
896    #[test]
897    fn sanitize_action_masks_hidden_indices() {
898        let env = make_env();
899        let ctx = env.replay_visibility_context();
900        let action = ActionDesc::MulliganSelect { hand_index: 3 };
901        let masked = env.sanitize_action_for_viewer(&action, 0, ctx);
902        match masked {
903            ActionDesc::MulliganSelect { hand_index } => assert_eq!(hand_index, u8::MAX),
904            _ => panic!("unexpected masked action"),
905        }
906    }
907}