//! 推给界面的快照类型。 //! //! # 为什么单独一个模块 //! //! 这些结构体的字段名就是**界面看到的 JSON 字段名**,所以它们是接口的一部分,值得 //! 集中放、集中改。放在这里而不是 Tauri 层,是因为其中几个的构造规则(比如"关掉 //! 诊断时不留数字")需要单元测试。 //! //! # 为什么 id 同时给数字和文本 //! //! 界面显示、日志、配置文件里用的都是 `0x3D4D8A9A` 这种十六进制形式。让 Rust 侧拼好 //! (`id_text`) 而不是让前端各处自己 `toString(16)`,可以避免"某处少补了前导零"这种 //! 只能靠肉眼比对才发现的不一致。 use serde::Serialize; use crate::armor::ArmorBoard; use crate::config::{Config, CrosshairConfig, GridConfig, HealthConfig, HitFeedbackConfig, Role}; use crate::receiver::{CameraInfo, SourceStats}; /// 把 id 写成界面上、配置文件里、抓包时都一致的形式。 pub fn id_text(id: u32) -> String { format!("0x{id:08X}") } /// 一块图传板。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CameraDto { pub id: u32, pub id_text: String, pub addr: String, pub online: bool, pub silence_ms: u64, pub firmware: u16, } impl From<&CameraInfo> for CameraDto { fn from(camera: &CameraInfo) -> Self { Self { id: camera.id, id_text: id_text(camera.id), addr: camera.addr.ip().to_string(), online: camera.is_online(), silence_ms: camera.silence().as_millis() as u64, firmware: camera.firmware, } } } /// 一块装甲板主板。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ArmorBoardDto { pub id: u32, pub id_text: String, pub addr: String, pub online: bool, /// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。 pub team: u8, /// 最后一次读数。整车一条血,所以这里一个数。 pub hp: u16, pub plates_seen: u8, pub reports_all_plates: bool, pub silence_ms: u64, } impl From<&ArmorBoard> for ArmorBoardDto { fn from(board: &ArmorBoard) -> Self { Self { id: board.id, id_text: id_text(board.id), addr: board.addr.ip().to_string(), online: board.is_online(), team: board.team, hp: board.hp, plates_seen: board.plates_seen.count_ones() as u8, reports_all_plates: board.reports_all_plates(), silence_ms: board.silence().as_millis() as u64, } } } /// 一路画面的读数。 /// /// # 关掉诊断时这些数字**不存在**,而不是被界面藏起来 /// /// 旧客户端在队伍端 release 下把这些字段填成空字符串。现在换成数字,规则不变:非诊断 /// 模式下它们被置零/置空。由编译模式决定而不是命令行参数——命令行开关意味着二进制里 /// 带着那套东西,忘开一次就泄露了。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CameraStatsDto { pub camera: u32, pub camera_text: String, pub online: bool, /// 界面直接显示这一行,所以"在线但还没画面"与"离线"的区别由这里给出,而不是让 /// 前端从 online + hasFrames 猜。 pub status: String, pub has_frames: bool, pub fps: f32, pub width: u16, pub height: u16, pub frames_shown: u64, pub frames_dropped: u64, pub loss_percent: f32, pub datagrams: u64, pub last_frame_bytes: usize, pub gap_ms: Option, } impl CameraStatsDto { /// `diagnostics` 为 false 时不产出技术数字(见类型文档)。 pub fn build( diagnostics: bool, camera: u32, online: bool, stats: &SourceStats, fps: f32, ) -> Self { let has_frames = stats.frames_shown > 0; // 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面" // 比让人以为板子挂了诚实。 // // 有画面时显示 id —— 这对调试有用。队伍端 release 下这一路只有自己绑定的板子, // 显示它不构成泄露(界面上根本没有别人的板子可选)。 let status = if !has_frames { if online { "已连接,等待画面…".to_string() } else { "离线".to_string() } } else { id_text(camera) }; if !diagnostics { return Self { camera, camera_text: id_text(camera), online, status, has_frames, fps: 0.0, width: 0, height: 0, frames_shown: 0, frames_dropped: 0, loss_percent: 0.0, datagrams: 0, last_frame_bytes: 0, gap_ms: None, }; } // 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是界面会显示成 // "0x0"——一个看起来像分辨率的假分辨率。用 has_frames 把它挡掉。 Self { camera, camera_text: id_text(camera), online, status, has_frames, fps: if has_frames { fps } else { 0.0 }, width: if has_frames { stats.width } else { 0 }, height: if has_frames { stats.height } else { 0 }, frames_shown: stats.frames_shown, frames_dropped: stats.frames_dropped, loss_percent: stats.loss_percent(), datagrams: stats.datagrams, last_frame_bytes: stats.last_frame_len, gap_ms: stats.gap_ms(), } } } /// 一边的血量。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TeamHealthDto { pub team: u8, pub name: String, /// 有没有可信的数据。false 时界面显示 `placeholder` 而不是 0——0 是一个看起来像 /// 真值的假值,现场看到 0 会以为对方已经没血了。 pub available: bool, pub placeholder: String, /// 整车血量(四条装甲板同值,所以取第一条)。 pub hp: Option, /// 四条装甲板各自的读数;`available` 为 true 时有效。 pub hps: Vec, /// 绑定的装甲板主板 id(用户端)/ 分配到的(裁判端)。 pub board: Option, pub board_text: Option, /// 血量偏低或已归零。 pub critical: bool, /// 这一边的胜负结论,由 [`resolve_outcomes`] 从**双方**的血量算出来。 /// /// 放在每一边的 DTO 上而不是单独一个全局字段:界面上一格画面就是一边,一个格子里 /// 要放的就是"从我这边看,现在是什么结果"。裁判端两格因此各看各的那份。 pub outcome: Option, } /// 一边看到的比赛结果。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum MatchOutcome { /// 对手归零,自己还活着。 Victory, /// 自己归零,对手还活着。 Defeat, /// 两边同时归零。不写成"平局"以外的任何一个字:同时打空时两边都看到"胜利"才是错的。 Draw, } impl TeamHealthDto { /// 还没有数据源。 pub fn absent(team: u8, board: Option, names: &[String; 2]) -> Self { Self { team, name: team_name(team, names), available: false, placeholder: format!( "装甲板未接入:等主板在 UDP {} 上广播血量", protocol::ARMOR_PORT ), hp: None, // 依然给出 4 个格子:界面用 `available` 决定颜色,空数组会让占位框整个消失, // 看起来像布局出错,而不是像"还没有数据"。 hps: vec![0; crate::receiver::ARMOR_BOARDS], board, board_text: board.map(id_text), critical: false, outcome: None, } } /// 有数据。 pub fn present( team: u8, board: Option, hps: [u16; crate::receiver::ARMOR_BOARDS], low: u16, names: &[String; 2], ) -> Self { let hp = hps.first().copied().unwrap_or(0); Self { team, name: team_name(team, names), available: true, placeholder: String::new(), hp: Some(hp), hps: hps.to_vec(), board, board_text: board.map(id_text), critical: hp == 0 || hp <= low, outcome: None, } } } /// 把双方的血量合起来算成各自看到的胜负结论,写回 `outcome`。 /// /// # 规则是有意不对称的 /// /// - **输只需要看自己**:自己归零就是输,哪怕对手那块板子的数据没收到。这一条要成立, /// 否则"我被打死了但对手的主板掉线"会显示成什么都没发生。 /// - **赢要看两边**:必须看到对手归零**且**自己还有血。少了后一半,一块从没上报过血量的 /// 自己方主板会让人"凭空胜利"。 /// - 两边同时归零是平局:那种情况下任何一边显示"胜利"都是错的。 /// /// 变量不足(对不上的队伍、只有一份 DTO)时什么都不写。 pub fn resolve_outcomes(health: &mut [TeamHealthDto]) { if health.len() != 2 { return; } let dead = |index: usize| health[index].available && health[index].hp == Some(0); let alive = |index: usize| matches!(health[index].hp, Some(hp) if hp > 0) && health[index].available; let (a_dead, b_dead) = (dead(0), dead(1)); let (a_alive, b_alive) = (alive(0), alive(1)); health[0].outcome = if a_dead && b_dead { Some(MatchOutcome::Draw) } else if b_dead && a_alive { Some(MatchOutcome::Victory) } else if a_dead { Some(MatchOutcome::Defeat) } else { None }; health[1].outcome = if a_dead && b_dead { Some(MatchOutcome::Draw) } else if a_dead && b_alive { Some(MatchOutcome::Victory) } else if b_dead { Some(MatchOutcome::Defeat) } else { None }; } /// 队伍名的兜底值(与 `Config::default().team_names` 一致)。 pub fn default_team_name(team: u8) -> String { crate::config::DEFAULT_TEAM_NAMES[usize::from(team.min(1))].to_string() } /// 这一边在界面上叫什么。 /// /// 配置里的名字是裁判端可以改的,所以名字必须**从这里来**,不能让界面各处自己拼 /// "队伍一"/"队伍二"——那种拼法会在改了名字之后留下一半旧名字。 pub fn team_name(team: u8, names: &[String; 2]) -> String { let name = names[usize::from(team.min(1))].trim(); if name.is_empty() { default_team_name(team) } else { name.to_string() } } /// 一次掉血(由 [`crate::armor::HealthTracker`] 派生)。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct HitDto { pub board: u32, pub team: u8, pub index: u8, pub from: u16, pub to: u16, pub damage: u16, /// Unix 毫秒。前端用它驱动闪红的动画,不需要自己计时。 pub at_ms: u64, } /// 录制状态。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RecordingDto { pub camera: u32, pub camera_text: String, pub active: bool, pub path: Option, /// 告诉 ffmpeg 的输入帧率。回放快放/慢放时,第一个要看的就是这个数。 pub input_fps: f32, pub frames_written: u64, /// 因为写入端跟不上而丢掉的帧。丢帧不报出来的话,回放里"少了半秒"会看起来像 /// 相机的问题。 pub frames_dropped: u64, pub started_ms: Option, pub error: Option, } /// 裁判端两个槽位。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SlotsDto { /// 左槽位 → 摄像头 id。 pub camera_a: Option, pub camera_b: Option, /// 左槽位 → 装甲板主板 id。 pub armor_a: Option, pub armor_b: Option, pub manual: bool, } /// 推给界面的完整状态。 /// /// 一次性给全,而不是让界面按需多次查询:5 Hz 下这条消息只有几百字节,而"界面各处 /// 分别拉数据"会引入一类难查的问题——两个面板显示的是不同时刻的状态(比如绑定改了 /// 一半)。前端因此只需要一个 reducer。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Snapshot { pub role: Role, pub team: u8, pub mirrored: bool, /// 演示模式:画面与血量是本地合成的,不是场上的板子。界面必须把它显示出来, /// 否则截图与验收会被误读成真实链路。 pub demo: bool, /// 是否产出技术读数(帧率/丢帧)。用户端 release 下为 false。 pub diagnostics: bool, /// 密码闸门是否已经打开(会话内)。 pub unlocked: bool, /// 发现列表是否放开(设置面板打开时)。开着才可能列出别人的板子。 pub discovery_open: bool, pub uptime_ms: u64, /// 连着这个后端的界面数。 pub clients: usize, pub config: ConfigView, pub cameras: Vec, pub armor_boards: Vec, /// 每块已知摄像头的读数。 pub stats: Vec, /// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。 pub slots: SlotsDto, /// **两端都是两条,顺序是队伍号 0、1**:用户端也要显示对手的血量(画面右上角)。 /// /// 界面按 `team` 取自己那一格,不靠数组下标——用户端绑的是队伍二时 `health[0]` 是别人。 pub health: Vec, pub recordings: Vec, /// 启动时的说明(旧配置迁移、端口占用等)。 pub notes: Vec, } /// 配置里可以给界面看的那部分。 /// /// **不含密码哈希**:界面只需要知道"设过没有",把 PHC 字符串送进 WebView 没有任何用处, /// 只是多一个能被日志、崩溃报告带走的东西。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConfigView { pub role: Role, /// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。 pub role_chosen: bool, pub team: u8, /// 两边在界面上显示的名字(裁判端可以改)。界面各处显示队伍名都要用它, /// 包括血条之外的地方(装甲板表、等待画面的占位文字)。 pub team_names: [String; 2], pub camera_id: Option, pub camera_id_text: Option, pub armor_id: Option, pub armor_id_text: Option, pub mirror: bool, pub password_set: bool, pub hud: HudView, pub recording_dir: String, pub ffmpeg: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct HudView { pub health: HealthConfig, pub crosshair: CrosshairConfig, pub grid: GridConfig, pub hit_feedback: HitFeedbackConfig, pub show_zoom: bool, pub show_stats: bool, } impl From<&Config> for ConfigView { fn from(config: &Config) -> Self { Self { role: config.role, role_chosen: config.role_chosen, team: config.team, team_names: config.team_names.clone(), camera_id: config.camera_id, camera_id_text: config.camera_id.map(id_text), armor_id: config.armor_id, armor_id_text: config.armor_id.map(id_text), mirror: config.mirror, password_set: config.password.is_some(), hud: HudView { health: config.hud.health.clone(), crosshair: config.hud.crosshair.clone(), grid: config.hud.grid.clone(), hit_feedback: config.hud.hit_feedback.clone(), show_zoom: config.hud.show_zoom, show_stats: config.hud.show_stats, }, recording_dir: config.recordings_dir().display().to_string(), ffmpeg: config.recording.ffmpeg.clone(), } } } #[cfg(test)] mod tests { use super::*; use crate::receiver::SourceStats; fn stats() -> SourceStats { let mut stats = SourceStats::default(); // 直接构造:SourceStats 的字段是 pub 的,只有 last_shown_at 是私有的。 stats.frames_shown = 440; stats.frames_dropped = 10; stats.datagrams = 8000; stats.width = 640; stats.height = 480; stats.last_frame_len = 22_000; stats } #[test] fn diagnostics_off_leaves_no_numbers_behind() { // 不是"界面藏起来",是**不存在**。 let dto = CameraStatsDto::build(false, 0x3D4D_8A9A, true, &stats(), 22.0); assert_eq!(dto.frames_shown, 0); assert_eq!(dto.frames_dropped, 0); assert_eq!(dto.datagrams, 0); assert_eq!(dto.fps, 0.0); assert_eq!(dto.width, 0); assert_eq!(dto.gap_ms, None); assert_eq!(dto.last_frame_bytes, 0); // 但界面仍然需要知道画面来没来。 assert!(dto.has_frames); assert!(dto.online); } #[test] fn diagnostics_on_reports_the_real_numbers() { let dto = CameraStatsDto::build(true, 0x3D4D_8A9A, true, &stats(), 22.0); assert_eq!(dto.frames_shown, 440); assert_eq!(dto.frames_dropped, 10); assert!((dto.loss_percent - 2.2222).abs() < 0.01); assert_eq!((dto.width, dto.height), (640, 480)); assert_eq!(dto.fps, 22.0); assert_eq!(dto.status, "0x3D4D8A9A"); } #[test] fn a_never_seen_camera_does_not_report_a_fake_resolution() { let dto = CameraStatsDto::build(true, 1, true, &SourceStats::default(), 0.0); assert_eq!(dto.status, "已连接,等待画面…"); assert_eq!((dto.width, dto.height), (0, 0)); assert!(!dto.has_frames, "0x0 would read like a real resolution"); } #[test] fn an_offline_camera_says_offline() { let dto = CameraStatsDto::build(true, 1, false, &SourceStats::default(), 0.0); assert_eq!(dto.status, "离线"); } /// 默认队名,测试里到处用。 fn names() -> [String; 2] { ["队伍一".to_string(), "队伍二".to_string()] } #[test] fn absent_health_is_a_placeholder_with_four_cells() { let dto = TeamHealthDto::absent(0, None, &names()); assert!(!dto.available); assert_eq!(dto.hp, None); assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS); assert!(!dto.placeholder.is_empty()); assert_eq!(dto.outcome, None, "没有数据时不下结论"); } #[test] fn critical_health_is_flagged_at_zero_and_at_the_threshold() { let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30, &names()); assert!(low.critical, "at the threshold"); let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30, &names()); assert!(!ok.critical); let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30, &names()); assert!(dead.critical); assert_eq!(dead.hp, Some(0)); } #[test] fn the_team_name_comes_from_the_config_not_from_a_hard_coded_string() { let custom = ["红队".to_string(), "蓝队".to_string()]; assert_eq!(TeamHealthDto::absent(1, None, &custom).name, "蓝队"); assert_eq!( TeamHealthDto::present(0, Some(1), [50; 4], 30, &custom).name, "红队" ); // 空名字回落默认值,而不是显示一个没有名字的血条。 let blank = ["".to_string(), " ".to_string()]; assert_eq!(team_name(0, &blank), "队伍一"); assert_eq!(team_name(1, &blank), "队伍二"); assert_eq!(team_name(9, &blank), "队伍二", "越界的队伍号按第二队处理"); } #[test] fn killing_the_opponent_reads_as_a_victory_for_the_other_side() { let mut health = vec![ TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()), TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()), ]; resolve_outcomes(&mut health); assert_eq!(health[0].outcome, Some(MatchOutcome::Victory)); assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat)); } #[test] fn nothing_is_announced_while_both_sides_are_alive() { let mut health = vec![ TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()), TeamHealthDto::present(1, Some(2), [40; 4], 30, &names()), ]; resolve_outcomes(&mut health); assert_eq!(health[0].outcome, None); assert_eq!(health[1].outcome, None); } #[test] fn both_sides_at_zero_is_a_draw_rather_than_two_victories() { let mut health = vec![ TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()), TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()), ]; resolve_outcomes(&mut health); assert_eq!(health[0].outcome, Some(MatchOutcome::Draw)); assert_eq!(health[1].outcome, Some(MatchOutcome::Draw)); } #[test] fn a_dead_team_loses_even_when_the_other_board_is_missing() { // 输只看自己:对手的主板掉线不该让"我被击毁"变成什么都没发生。 let mut health = vec![ TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()), TeamHealthDto::absent(1, None, &names()), ]; resolve_outcomes(&mut health); assert_eq!(health[0].outcome, Some(MatchOutcome::Defeat)); assert_eq!(health[1].outcome, None, "没有自己数据的这边不下结论"); } #[test] fn a_missing_own_board_does_not_produce_a_victory() { // 赢要看两边:只看到对手归零、不知道自己还有没有血时不能宣布胜利。 let mut health = vec![ TeamHealthDto::absent(0, None, &names()), TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()), ]; resolve_outcomes(&mut health); assert_eq!(health[0].outcome, None); assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat)); } #[test] fn the_snapshot_serialises_the_outcome_as_a_plain_word() { let mut health = vec![ TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()), TeamHealthDto::present(1, Some(2), [50; 4], 30, &names()), ]; resolve_outcomes(&mut health); let json = serde_json::to_string(&health[0]).unwrap(); assert!(json.contains(r#""outcome":"defeat""#), "got {json}"); let json = serde_json::to_string(&health[1]).unwrap(); assert!(json.contains(r#""outcome":"victory""#), "got {json}"); } #[test] fn a_recording_reports_the_framerate_it_told_ffmpeg() { // 回放速度不对时,这是唯一能解释原因的数字。 let dto = RecordingDto { camera: 1, camera_text: id_text(1), active: true, path: None, input_fps: 21.5, frames_written: 0, frames_dropped: 0, started_ms: None, error: None, }; let json = serde_json::to_string(&dto).unwrap(); assert!(json.contains(r#""inputFps":21.5"#), "got {json}"); } #[test] fn the_config_view_never_carries_the_password_hash() { let mut config = Config::default(); config.role = Role::Team; config.password = Some("$argon2id$super$secret$hash".into()); let view = ConfigView::from(&config); assert!(view.password_set); let json = serde_json::to_string(&view).unwrap(); assert!( !json.contains("argon2id"), "the hash must not reach the webview: {json}" ); } #[test] fn ids_are_formatted_like_everywhere_else() { assert_eq!(id_text(0x3D4D_8A9A), "0x3D4D8A9A"); assert_eq!(id_text(1), "0x00000001", "leading zeros matter for eyeballing"); } }