//! 裁判端两个槽位对应哪两块摄像头。 //! //! # 规则 //! //! 1. **还在线的槽位不换人。** 板子短暂掉线又回来时,画面留在原位。 //! 2. **没人用的板子才拿来填。** 候选里排除已经被另一个槽位占着的 id——这一条是 //! 修一个真 bug:以前的实现只排除"这个槽位自己原来那个 id",于是队伍一那块板子 //! 一离线,它的槽位就会去拿队伍二那块,**两格显示同一块板子**,看起来像图传串了。 //! 3. **掉线的槽位保留它的 id。** 显示"离线"而不是变回空槽,这样它回来时还在原位, //! 也不会把画面顺序重排。 //! 4. **没有候选就不动。** 只有一块板在场时,另一个槽位空着——空着比显示一份复制品 //! 诚实。 //! //! # 为什么值得单独一个模块加测试 //! //! 这个逻辑出错的**表现**是"两路画面看起来串了"。现场的排查方向会是网线、AP、 //! 板子,而不是二十行分配代码。旧客户端在 README 里为它写了整整一节,但仍然漏掉了 //! 规则 2。所以现在它有了直接对着场景写的测试。 /// 两个槽位。索引 0 是左(队伍一),1 是右(队伍二)。 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SlotAllocator { slots: [Option; 2], } impl SlotAllocator { pub fn new() -> Self { Self::default() } /// 当前分配。 pub fn slots(&self) -> [Option; 2] { self.slots } pub fn slot(&self, index: usize) -> Option { self.slots.get(index).copied().flatten() } /// 手动指定一个槽位(裁判端的"允许手动覆盖")。 pub fn force(&mut self, index: usize, id: Option) { if let Some(slot) = self.slots.get_mut(index) { *slot = id; } } /// 按当前在线的摄像头更新分配,返回是否发生了变化。 /// /// `online` 必须按 id 排序,调用方用 [`crate::receiver::Discovery::online_cameras`] /// 得到的就是排好序的——顺序稳定,分配才不会每轮抖动。 pub fn assign(&mut self, online: &[u32]) -> bool { let before = self.slots; for index in 0..self.slots.len() { // 规则 1:还在场就不动。 if self.slots[index].is_some_and(|id| online.contains(&id)) { continue; } // 规则 2:另一个槽位占着的 id 不能拿。 let taken: Vec = (0..self.slots.len()) .filter(|other| *other != index) .filter_map(|other| self.slots[other]) .collect(); let candidate = online .iter() .copied() .find(|id| !taken.contains(id) && self.slots[index] != Some(*id)); // 规则 4:没有候选就保留原样(规则 3:掉线的 id 留着)。 if let Some(id) = candidate { self.slots[index] = Some(id); } } self.slots != before } } #[cfg(test)] mod tests { use super::*; #[test] fn two_boards_fill_both_slots_left_to_right() { let mut slots = SlotAllocator::new(); assert!(slots.assign(&[0xAAAA, 0xBBBB])); assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]); assert!(!slots.assign(&[0xAAAA, 0xBBBB]), "nothing changed"); } #[test] fn one_board_never_fills_both_slots() { // 旧实现会在两块都空时每轮取前两个,只有一块板在场时两格显示同一块画面。 // 裁判看到两边一模一样,会以为两队的图传串了。 let mut slots = SlotAllocator::new(); slots.assign(&[0xAAAA]); assert_eq!(slots.slots(), [Some(0xAAAA), None]); } #[test] fn an_online_slot_keeps_its_board_when_the_other_goes_offline() { // 这是本项目里真实出现过的 bug:队伍一的板子一离线,它的槽位去拿了队伍二 // 那块,于是两格都显示队伍二——而 B 的槽位本身没动。 let mut slots = SlotAllocator::new(); slots.assign(&[0xAAAA, 0xBBBB]); slots.assign(&[0xBBBB]); assert_eq!( slots.slots(), [Some(0xAAAA), Some(0xBBBB)], "A 掉线的槽位保留它的 id(显示离线),B 原地不动,绝不能出现两块 B" ); } #[test] fn a_returning_board_lands_back_in_its_own_slot() { let mut slots = SlotAllocator::new(); slots.assign(&[0xAAAA, 0xBBBB]); slots.assign(&[0xBBBB]); slots.assign(&[0xAAAA, 0xBBBB]); assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]); } #[test] fn a_spare_board_does_not_land_twice() { let mut slots = SlotAllocator::new(); slots.assign(&[0xAAAA, 0xBBBB]); // 两块都掉线,然后来了一块新的 C:只填一个槽位。 slots.assign(&[0xCCCC]); assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xBBBB)]); // C 填了左边,右边仍是掉线的 B;再来一块 D 才填右边。 slots.assign(&[0xCCCC, 0xDDDD]); assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xDDDD)]); } #[test] fn a_board_coming_back_does_not_thrash_the_slot_it_left() { // "板子短暂掉线又回来,槽位原地打转":掉线的槽位不该立刻把自己换掉。 let mut slots = SlotAllocator::new(); slots.assign(&[0xAAAA]); // A 掉线、B 上线:A 的槽位改拿 B。 slots.assign(&[0xBBBB]); assert_eq!(slots.slots(), [Some(0xBBBB), None]); // A 回来,两块都在:B 已经在槽位里且在线,A 只能等另一个槽位——它空着,于是 // A 落到右边。这是有意的:已经显示出来的画面不该因为一块板子回来就跳到别处。 slots.assign(&[0xAAAA, 0xBBBB]); assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]); } #[test] fn forcing_a_slot_overrides_the_automatic_choice() { let mut slots = SlotAllocator::new(); slots.assign(&[0xAAAA, 0xBBBB]); slots.force(0, Some(0xBBBB)); slots.force(1, Some(0xAAAA)); assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]); // 手动模式下依然是手动值:assign 只对"不在线"的槽位动手。 slots.assign(&[0xAAAA, 0xBBBB]); assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]); } #[test] fn losing_everything_leaves_the_last_known_ids() { let mut slots = SlotAllocator::new(); slots.assign(&[0xAAAA, 0xBBBB]); assert!(!slots.assign(&[]), "nothing to reassign"); assert_eq!( slots.slots(), [Some(0xAAAA), Some(0xBBBB)], "掉线的槽位保留 id,界面才能说清在等谁" ); } }