desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/ (该目录不进仓库)。 1. 客户端显示对手的血量 快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠 数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己 绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用 `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。 2. 血条改到画面两个上角,平行四边形,一条分 5 格 左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg), 右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。 条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、 拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。 注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条 从外侧开始灭(代码里写了注释钉住这件事)。 3. 受击画面不抖动 删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但 **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的 配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关, 并有单测钉住"旧配置读得进来、默认不抖"。 4. 血量归零算击败,不显示"损毁" 血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在 Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己** (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血), 两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也 没有"这局结束了"的痕迹。 5. 裁判端可以改队伍名 两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到 「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个 两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照, 紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name` (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。 名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置 面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。 测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
This commit is contained in:
+119
-3
@@ -46,6 +46,18 @@ use serde::{Deserialize, Serialize};
|
||||
/// 用处是将来真需要迁移时有个明确的判据,以及现场排查时能一眼看出文件是哪一版写的。
|
||||
pub const CONFIG_VERSION: u32 = 1;
|
||||
|
||||
/// 队伍名的兜底值,下标就是队伍号。
|
||||
///
|
||||
/// 界面上到处都在用这两个名字(血条、装甲板表、占位文字),所以它们是**默认值**而不是
|
||||
/// "没配置":配置里留空时回落到这里,而不是显示一个空白。
|
||||
pub const DEFAULT_TEAM_NAMES: [&str; 2] = ["队伍一", "队伍二"];
|
||||
|
||||
/// 队伍名的最大长度(字符,不是字节)。
|
||||
///
|
||||
/// 名字会画在血条左上角/右上角那一小块地方,超长的名字会把血条挤走。按**字符**截断而不是
|
||||
/// 字节:中文按字节截断会切出半个字,界面上显示成一个方块。
|
||||
pub const TEAM_NAME_MAX_CHARS: usize = 12;
|
||||
|
||||
/// 客户端身份。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -245,11 +257,19 @@ impl HealthConfig {
|
||||
pub struct HitFeedbackConfig {
|
||||
/// 整屏闪一下红。
|
||||
pub flash: bool,
|
||||
/// 画面抖一下。
|
||||
/// **已废弃,保留只是为了兼容旧配置文件。**
|
||||
///
|
||||
/// 命中时抖动画面的做法去掉了:抖动恰恰发生在最需要看清画面的那一下,而且它让**整幅
|
||||
/// 图**(包括瞄准器)跟着动,操作手会误以为车在晃。现在这个字段读进来就丢掉,界面不再
|
||||
/// 画抖动、设置面板里也没有这个开关。
|
||||
///
|
||||
/// 不删掉字段的理由很实际:这个文件是 `deny_unknown_fields` 的,删字段会让所有已经写
|
||||
/// 过 `shake = true` 的配置**直接解析失败**,而配置解析失败等于程序起不来。
|
||||
#[serde(default)]
|
||||
pub shake: bool,
|
||||
/// 在瞄点旁标出伤害数字。
|
||||
pub marker: bool,
|
||||
/// 闪红与抖动的强度(0.2–2.0)。
|
||||
/// 闪红与伤害数字的强度(0.2–2.0)。
|
||||
pub intensity: f32,
|
||||
}
|
||||
|
||||
@@ -257,7 +277,8 @@ impl Default for HitFeedbackConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flash: true,
|
||||
shake: true,
|
||||
// 见字段文档:不再抖动。默认值也就是"读旧配置时会被解析成什么"。
|
||||
shake: false,
|
||||
marker: true,
|
||||
intensity: 1.0,
|
||||
}
|
||||
@@ -401,6 +422,14 @@ pub struct Config {
|
||||
pub role_chosen: bool,
|
||||
/// 用户端属于哪一边:0 = 队伍一,1 = 队伍二。
|
||||
pub team: u8,
|
||||
/// 两边在界面上显示的名字,下标就是队伍号(裁判端可以直接改)。
|
||||
///
|
||||
/// 放在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的队伍叫什么"
|
||||
/// 是**比赛**的属性,不是裁判端这个身份的属性。
|
||||
///
|
||||
/// 空字符串会回落到 [`DEFAULT_TEAM_NAMES`](见 [`Config::normalized`])——名字是画在
|
||||
/// 血条角上的,留空会在现场显示成一个没有名字的血条,而不是"没配置"。
|
||||
pub team_names: [String; 2],
|
||||
/// 绑定的图传板(MAC 派生,重启不变)。
|
||||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||||
pub camera_id: Option<u32>,
|
||||
@@ -427,6 +456,10 @@ impl Default for Config {
|
||||
role: Role::Referee,
|
||||
role_chosen: false,
|
||||
team: 0,
|
||||
team_names: [
|
||||
DEFAULT_TEAM_NAMES[0].to_string(),
|
||||
DEFAULT_TEAM_NAMES[1].to_string(),
|
||||
],
|
||||
camera_id: None,
|
||||
armor_id: None,
|
||||
mirror: true,
|
||||
@@ -509,6 +542,10 @@ impl Config {
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.hud = self.hud.clamped();
|
||||
self.recording = self.recording.clamped();
|
||||
self.team_names = [
|
||||
sanitize_team_name(&self.team_names[0], 0),
|
||||
sanitize_team_name(&self.team_names[1], 1),
|
||||
];
|
||||
self
|
||||
}
|
||||
|
||||
@@ -539,6 +576,20 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// 队伍名规范化:合并空白、按字符截断、空则回落到默认名。
|
||||
///
|
||||
/// 名字画在血条左上角/右上角那一小块地方,所以这三件事都是为了"不把布局弄坏":换行会在角上
|
||||
/// 多顶出一行、超长会把血条挤走、空串会显示成一个没有名字的血条(而不是"没配置")。
|
||||
pub fn sanitize_team_name(name: &str, team: usize) -> String {
|
||||
let fallback = DEFAULT_TEAM_NAMES[team.min(1)];
|
||||
|
||||
// 合并内部空白(含换行与制表符):`"队伍 一"` 保留单个空格,`"a\nb"` 变成 `"a b"`。
|
||||
let collapsed = name.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let text = if collapsed.is_empty() { fallback } else { collapsed.as_str() };
|
||||
|
||||
text.chars().take(TEAM_NAME_MAX_CHARS).collect()
|
||||
}
|
||||
|
||||
/// 配置文件开头那段自我说明。
|
||||
const HEADER: &str = "\
|
||||
# cam-desktop 配置。
|
||||
@@ -551,6 +602,8 @@ const HEADER: &str = "\
|
||||
# team = 用户端:一块画面 + 自己的血条与瞄准器
|
||||
# referee = 裁判端:两块画面 + 双方血量 + 装甲板列表 + 录制
|
||||
# team : 0 = 队伍一, 1 = 队伍二(仅 team 端用)
|
||||
# teamNames: 两边在界面上显示的名字,下标就是队伍号;裁判端可以直接改。
|
||||
# 超过 12 个字会被截断,留空则回落到「队伍一 / 队伍二」。
|
||||
# cameraId : 图传板 id,由 MAC 派生,重启不变,所以绑定是稳的
|
||||
# armorId : 装甲板主板 id,同上
|
||||
# mirror : true = 左右镜像(摄像头装反了,默认)
|
||||
@@ -816,6 +869,7 @@ mod tests {
|
||||
config.role = Role::Team;
|
||||
config.role_chosen = true;
|
||||
config.team = 1;
|
||||
config.team_names = ["红队".to_string(), "蓝队".to_string()];
|
||||
config.camera_id = Some(0x3D4D_8A9A);
|
||||
config.armor_id = Some(0x00AB_CDEF);
|
||||
config.mirror = false;
|
||||
@@ -832,6 +886,7 @@ mod tests {
|
||||
assert_eq!(back.role, Role::Team);
|
||||
assert!(back.role_chosen);
|
||||
assert_eq!(back.team, 1);
|
||||
assert_eq!(back.team_names, config.team_names);
|
||||
assert_eq!(back.camera_id, Some(0x3D4D_8A9A));
|
||||
assert_eq!(back.armor_id, Some(0x00AB_CDEF));
|
||||
assert!(!back.mirror);
|
||||
@@ -933,6 +988,67 @@ roleChosen = true"#)
|
||||
assert!(hud.crosshair.offset_x <= 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_names_are_sanitized_before_they_reach_the_disk() {
|
||||
let mut config = Config::default();
|
||||
// 前后空白 + 14 个字(超过上限)+ 换行 + 一个纯空白的名字。
|
||||
config.team_names = [
|
||||
" 很长的队伍名字超过了十二个字 ".to_string(),
|
||||
" \n\t ".to_string(),
|
||||
];
|
||||
|
||||
let config = config.normalized();
|
||||
|
||||
assert_eq!(
|
||||
config.team_names[0].chars().count(),
|
||||
TEAM_NAME_MAX_CHARS,
|
||||
"按字符截断:按字节截断会切出半个字"
|
||||
);
|
||||
assert_eq!(config.team_names[0], "很长的队伍名字超过了十二");
|
||||
assert_eq!(
|
||||
config.team_names[1], DEFAULT_TEAM_NAMES[1],
|
||||
"留空回落到默认名,而不是显示一个没有名字的血条"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_names_survive_the_round_trip() {
|
||||
let mut config = Config::default();
|
||||
config.team_names = ["红队".to_string(), "Blue Team".to_string()];
|
||||
|
||||
let text = config.render().expect("renders");
|
||||
assert!(text.contains("teamNames"), "key name matters:\n{text}");
|
||||
|
||||
let back = Config::parse(&text).expect("parses");
|
||||
assert_eq!(back.team_names, config.team_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_config_without_team_names_gets_the_defaults() {
|
||||
let config = Config::parse("role = \"team\"\nteam = 1").expect("parses");
|
||||
assert_eq!(
|
||||
config.team_names,
|
||||
[
|
||||
DEFAULT_TEAM_NAMES[0].to_string(),
|
||||
DEFAULT_TEAM_NAMES[1].to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_config_with_shake_true_still_loads() {
|
||||
// 抖动去掉了,但字段必须还能读:这个文件是 deny_unknown_fields 的,删字段会让
|
||||
// 所有写过 shake = true 的配置解析失败,而配置解析失败等于程序起不来。
|
||||
let config = Config::parse("[hud.hitFeedback]\nshake = true")
|
||||
.expect("旧配置必须还能读进来");
|
||||
|
||||
assert!(config.hud.hit_feedback.shake, "字段留着,只是不再生效");
|
||||
assert!(
|
||||
!HitFeedbackConfig::default().shake,
|
||||
"默认值是不抖动"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_do_not_lose_the_password_hash() {
|
||||
let dir = std::env::temp_dir().join(format!("cam-desktop-cfg-{}", std::process::id()));
|
||||
|
||||
+205
-15
@@ -194,14 +194,31 @@ pub struct TeamHealthDto {
|
||||
pub board_text: Option<String>,
|
||||
/// 血量偏低或已归零。
|
||||
pub critical: bool,
|
||||
/// 这一边的胜负结论,由 [`resolve_outcomes`] 从**双方**的血量算出来。
|
||||
///
|
||||
/// 放在每一边的 DTO 上而不是单独一个全局字段:界面上一格画面就是一边,一个格子里
|
||||
/// 要放的就是"从我这边看,现在是什么结果"。裁判端两格因此各看各的那份。
|
||||
pub outcome: Option<MatchOutcome>,
|
||||
}
|
||||
|
||||
/// 一边看到的比赛结果。
|
||||
#[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<u32>) -> Self {
|
||||
pub fn absent(team: u8, board: Option<u32>, names: &[String; 2]) -> Self {
|
||||
Self {
|
||||
team,
|
||||
name: team_name(team),
|
||||
name: team_name(team, names),
|
||||
available: false,
|
||||
placeholder: format!(
|
||||
"装甲板未接入:等主板在 UDP {} 上广播血量",
|
||||
@@ -214,16 +231,23 @@ impl TeamHealthDto {
|
||||
board,
|
||||
board_text: board.map(id_text),
|
||||
critical: false,
|
||||
outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 有数据。
|
||||
pub fn present(team: u8, board: Option<u32>, hps: [u16; crate::receiver::ARMOR_BOARDS], low: u16) -> Self {
|
||||
pub fn present(
|
||||
team: u8,
|
||||
board: Option<u32>,
|
||||
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),
|
||||
name: team_name(team, names),
|
||||
available: true,
|
||||
placeholder: String::new(),
|
||||
hp: Some(hp),
|
||||
@@ -231,15 +255,70 @@ impl TeamHealthDto {
|
||||
board,
|
||||
board_text: board.map(id_text),
|
||||
critical: hp == 0 || hp <= low,
|
||||
outcome: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 队伍名。
|
||||
pub fn team_name(team: u8) -> String {
|
||||
match team {
|
||||
0 => "队伍一".to_string(),
|
||||
_ => "队伍二".to_string(),
|
||||
/// 把双方的血量合起来算成各自看到的胜负结论,写回 `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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +332,7 @@ pub struct HitDto {
|
||||
pub from: u16,
|
||||
pub to: u16,
|
||||
pub damage: u16,
|
||||
/// Unix 毫秒。前端用它驱动闪红/抖动的动画,不需要自己计时。
|
||||
/// Unix 毫秒。前端用它驱动闪红的动画,不需要自己计时。
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
@@ -318,7 +397,9 @@ pub struct Snapshot {
|
||||
pub stats: Vec<CameraStatsDto>,
|
||||
/// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。
|
||||
pub slots: SlotsDto,
|
||||
/// 用户端一条(自己那边),裁判端两条。
|
||||
/// **两端都是两条,顺序是队伍号 0、1**:用户端也要显示对手的血量(画面右上角)。
|
||||
///
|
||||
/// 界面按 `team` 取自己那一格,不靠数组下标——用户端绑的是队伍二时 `health[0]` 是别人。
|
||||
pub health: Vec<TeamHealthDto>,
|
||||
pub recordings: Vec<RecordingDto>,
|
||||
/// 启动时的说明(旧配置迁移、端口占用等)。
|
||||
@@ -336,6 +417,9 @@ pub struct ConfigView {
|
||||
/// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。
|
||||
pub role_chosen: bool,
|
||||
pub team: u8,
|
||||
/// 两边在界面上显示的名字(裁判端可以改)。界面各处显示队伍名都要用它,
|
||||
/// 包括血条之外的地方(装甲板表、等待画面的占位文字)。
|
||||
pub team_names: [String; 2],
|
||||
pub camera_id: Option<u32>,
|
||||
pub camera_id_text: Option<String>,
|
||||
pub armor_id: Option<u32>,
|
||||
@@ -364,6 +448,7 @@ impl From<&Config> for ConfigView {
|
||||
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,
|
||||
@@ -445,29 +530,134 @@ mod tests {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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() {
|
||||
// 回放速度不对时,这是唯一能解释原因的数字。
|
||||
|
||||
Reference in New Issue
Block a user