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 }
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}