use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum BarkBattleDifficultyPreset { Easy, Normal, Hard, } impl Default for BarkBattleDifficultyPreset { fn default() -> Self { Self::Normal } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum BarkBattleServerResult { PlayerWin, OpponentWin, Draw, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum BarkBattleFinishStatus { Accepted, AcceptedWithFlags, Rejected, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum BarkBattleAssetSlot { PlayerCharacter, OpponentCharacter, UiBackground, } #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleReplacementConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleConfigEditorPayload { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub theme_description: String, pub player_image_description: String, pub opponent_image_description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleDraftCreateRequest { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub theme_description: String, pub player_image_description: String, pub opponent_image_description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, } impl From for BarkBattleConfigEditorPayload { fn from(value: BarkBattleDraftCreateRequest) -> Self { Self { title: value.title, description: value.description, theme_description: value.theme_description, player_image_description: value.player_image_description, opponent_image_description: value.opponent_image_description, onomatopoeia: value.onomatopoeia, player_character_image_src: value.player_character_image_src, opponent_character_image_src: value.opponent_character_image_src, ui_background_image_src: value.ui_background_image_src, difficulty_preset: value.difficulty_preset, } } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleDraftConfigUpdateRequest { pub draft_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub work_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub config_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ruleset_version: Option, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub theme_description: String, pub player_image_description: String, pub opponent_image_description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, } impl From for BarkBattleConfigEditorPayload { fn from(value: BarkBattleDraftConfigUpdateRequest) -> Self { Self { title: value.title, description: value.description, theme_description: value.theme_description, player_image_description: value.player_image_description, opponent_image_description: value.opponent_image_description, onomatopoeia: value.onomatopoeia, player_character_image_src: value.player_character_image_src, opponent_character_image_src: value.opponent_character_image_src, ui_background_image_src: value.ui_background_image_src, difficulty_preset: value.difficulty_preset, } } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleWorkPublishRequest { pub draft_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub work_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub published_snapshot: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleImageAssetGenerateRequest { pub slot: BarkBattleAssetSlot, #[serde(default, skip_serializing_if = "Option::is_none")] pub draft_id: Option, pub config: BarkBattleConfigEditorPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleGeneratedImageAsset { pub image_src: String, pub asset_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_type: Option, pub model: String, pub size: String, pub task_id: String, pub prompt: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub actual_prompt: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleDraftConfig { pub draft_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub work_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub config_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ruleset_version: Option, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub theme_description: String, pub player_image_description: String, pub opponent_image_description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, pub updated_at: String, } impl Default for BarkBattleDraftConfig { fn default() -> Self { Self { draft_id: String::new(), work_id: None, config_version: None, ruleset_version: None, title: String::new(), description: None, theme_description: String::new(), player_image_description: String::new(), opponent_image_description: String::new(), onomatopoeia: None, player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, difficulty_preset: BarkBattleDifficultyPreset::Normal, updated_at: String::new(), } } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattlePublishedConfig { pub work_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub draft_id: Option, pub config_version: u32, pub ruleset_version: String, pub play_type_id: String, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub theme_description: String, pub player_image_description: String, pub opponent_image_description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, pub difficulty_preset: BarkBattleDifficultyPreset, pub updated_at: String, pub published_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRuntimeConfig { pub work_id: String, pub config_version: u32, pub ruleset_version: String, pub play_type_id: String, pub duration_ms: u64, pub energy_min: f32, pub energy_max: f32, pub draw_threshold: f32, pub min_bark_gap_ms: u64, pub difficulty_preset: BarkBattleDifficultyPreset, pub theme_description: String, pub player_image_description: String, pub opponent_image_description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleWorkSummary { pub work_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub draft_id: Option, pub owner_user_id: String, pub author_display_name: String, pub title: String, pub summary: String, pub theme_description: String, pub player_image_description: String, pub opponent_image_description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, pub difficulty_preset: BarkBattleDifficultyPreset, pub status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub generation_status: Option, pub publish_ready: bool, pub play_count: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub finish_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub win_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub draw_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub loss_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub recent_play_count_7d: Option, pub updated_at: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub published_at: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleWorksResponse { #[serde(default)] pub items: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleWorkDetailResponse { pub item: BarkBattleWorkSummary, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRunStartRequest { pub work_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub config_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_route: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub client_runtime_version: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRunStartResponse { pub run_id: String, pub run_token: String, pub work_id: String, pub config_version: u32, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, pub runtime_config: BarkBattleRuntimeConfig, pub server_started_at: String, pub expires_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleDerivedMetrics { pub trigger_count: u32, pub max_volume: f32, pub average_volume: f32, pub final_energy: f32, pub combo_max: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRunFinishRequest { pub work_id: String, pub run_id: String, pub run_token: String, pub config_version: u32, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, pub client_started_at: String, pub client_finished_at: String, pub duration_ms: u64, pub derived_metrics: BarkBattleDerivedMetrics, #[serde(default, skip_serializing_if = "Option::is_none")] pub client_result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sample_digest: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub client_runtime_version: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleScoreSummary { pub duration_ms: u64, pub trigger_count: u32, pub max_volume: f32, pub average_volume: f32, pub final_energy: f32, pub combo_max: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRunFinishResponse { pub status: BarkBattleFinishStatus, pub run_id: String, pub work_id: String, pub config_version: u32, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, pub server_result: BarkBattleServerResult, pub score_summary: BarkBattleScoreSummary, #[serde(default, skip_serializing_if = "Option::is_none")] pub leaderboard_score: Option, #[serde(default)] pub anti_cheat_flags: Vec, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleLeaderboardEntry { pub rank: u32, pub run_id: String, pub work_id: String, pub config_version: u32, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, pub display_name: String, pub server_result: BarkBattleServerResult, pub score_summary: BarkBattleScoreSummary, pub leaderboard_score: u64, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleLeaderboardResponse { pub work_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub config_version: Option, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, #[serde(default)] pub entries: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub viewer_best: Option, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattlePersonalHistoryItem { pub run_id: String, pub work_id: String, pub config_version: u32, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, pub server_result: BarkBattleServerResult, pub score_summary: BarkBattleScoreSummary, #[serde(default, skip_serializing_if = "Option::is_none")] pub leaderboard_score: Option, #[serde(default)] pub anti_cheat_flags: Vec, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattlePersonalBestSummary { pub work_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub config_version: Option, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, #[serde(default, skip_serializing_if = "Option::is_none")] pub best_leaderboard_score: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub best_final_energy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub best_trigger_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub best_max_volume: Option, pub win_count: u64, pub draw_count: u64, pub loss_count: u64, pub finish_count: u64, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattlePersonalHistoryResponse { #[serde(default, skip_serializing_if = "Option::is_none")] pub work_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub difficulty_preset: Option, #[serde(default)] pub items: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub best_summary: Option, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleWorkStats { pub work_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub config_version: Option, pub ruleset_version: String, pub difficulty_preset: BarkBattleDifficultyPreset, pub play_start_count: u64, pub finish_count: u64, pub win_count: u64, pub draw_count: u64, pub loss_count: u64, pub flagged_count: u64, pub leaderboard_entry_count: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub best_leaderboard_score: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub best_final_energy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub average_final_energy: Option, pub updated_at: String, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn editor_and_runtime_contract_use_description_fields_only() { let editor = BarkBattleConfigEditorPayload { title: "周末狗狗杯".to_string(), description: Some("轻配置草稿".to_string()), theme_description: "霓虹公园里的欢乐擂台".to_string(), player_image_description: "戴红围巾的柴犬主角".to_string(), opponent_image_description: "蓝色护目镜哈士奇对手".to_string(), onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), difficulty_preset: BarkBattleDifficultyPreset::Hard, }; let payload = serde_json::to_value(editor).expect("config should serialize"); assert_eq!(payload["themeDescription"], json!("霓虹公园里的欢乐擂台")); assert_eq!( payload["playerImageDescription"], json!("戴红围巾的柴犬主角") ); assert_eq!( payload["opponentImageDescription"], json!("蓝色护目镜哈士奇对手") ); assert_eq!(payload["onomatopoeia"], json!(["轰汪!", "炸场!"])); for removed in [ "themePreset", "playerDogSkinPreset", "opponentDogSkinPreset", "barkSoundSrc", "leaderboardEnabled", ] { assert!( !payload.as_object().unwrap().contains_key(removed), "{removed} must not remain in v1 public config payload" ); } let runtime = BarkBattleRuntimeConfig { work_id: "bark-battle-work-1".to_string(), config_version: 1, ruleset_version: "bark-battle-ruleset-v1".to_string(), play_type_id: "bark-battle".to_string(), duration_ms: 30_000, energy_min: 0.0, energy_max: 100.0, draw_threshold: 5.0, min_bark_gap_ms: 220, difficulty_preset: BarkBattleDifficultyPreset::Normal, theme_description: "阳光草坪".to_string(), player_image_description: "小柴犬".to_string(), opponent_image_description: "大金毛".to_string(), onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, updated_at: "2026-05-20T00:00:00Z".to_string(), }; let payload = serde_json::to_value(runtime).expect("runtime should serialize"); assert_eq!(payload["themeDescription"], json!("阳光草坪")); assert!( !payload .as_object() .unwrap() .contains_key("leaderboardEnabled") ); assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); } #[test] fn work_summary_responses_use_public_gallery_contract() { let response = BarkBattleWorksResponse { items: vec![BarkBattleWorkSummary { work_id: "bark-battle-work-1".to_string(), draft_id: Some("bark-battle-draft-1".to_string()), owner_user_id: "user-1".to_string(), author_display_name: "玩家".to_string(), title: "汪汪测试杯".to_string(), summary: "轻量公开卡片".to_string(), theme_description: "阳光草坪".to_string(), player_image_description: "小柴犬".to_string(), opponent_image_description: "大金毛".to_string(), onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, difficulty_preset: BarkBattleDifficultyPreset::Normal, status: "published".to_string(), generation_status: Some("ready".to_string()), publish_ready: true, play_count: 3, finish_count: Some(2), win_count: Some(1), draw_count: Some(1), loss_count: Some(0), recent_play_count_7d: Some(2), updated_at: "2026-05-20T00:00:00Z".to_string(), published_at: Some("2026-05-20T00:00:00Z".to_string()), }], }; let payload = serde_json::to_value(response).expect("works response should serialize"); assert_eq!(payload["items"][0]["themeDescription"], json!("阳光草坪")); assert_eq!(payload["items"][0]["recentPlayCount7d"], json!(2)); assert_eq!(payload["items"][0]["status"], json!("published")); } #[test] fn draft_config_defaults_to_normal_difficulty() { let config = BarkBattleDraftConfig::default(); assert_eq!(config.difficulty_preset, BarkBattleDifficultyPreset::Normal); } #[test] fn run_requests_carry_config_and_ruleset_identity() { let start = BarkBattleRunStartRequest { work_id: "work-1".to_string(), config_version: Some(3), source_route: Some("gallery".to_string()), client_runtime_version: Some("runtime-v1".to_string()), }; let start_payload = serde_json::to_value(start).expect("start request should serialize"); assert_eq!(start_payload["workId"], json!("work-1")); assert_eq!(start_payload["configVersion"], json!(3)); assert_eq!(start_payload["sourceRoute"], json!("gallery")); let finish = BarkBattleRunFinishRequest { work_id: "work-1".to_string(), run_id: "run-1".to_string(), run_token: "token-1".to_string(), config_version: 3, ruleset_version: "bark-battle-ruleset-v1".to_string(), difficulty_preset: BarkBattleDifficultyPreset::Hard, client_started_at: "2026-05-13T11:00:00Z".to_string(), client_finished_at: "2026-05-13T11:00:30Z".to_string(), duration_ms: 30_000, derived_metrics: BarkBattleDerivedMetrics { trigger_count: 12, max_volume: 0.95, average_volume: 0.62, final_energy: 88.5, combo_max: 7, }, client_result: Some(BarkBattleServerResult::PlayerWin), sample_digest: Some("digest-1".to_string()), client_runtime_version: None, }; let finish_payload = serde_json::to_value(finish).expect("finish request should serialize"); assert_eq!(finish_payload["configVersion"], json!(3)); assert_eq!( finish_payload["rulesetVersion"], json!("bark-battle-ruleset-v1") ); assert_eq!(finish_payload["difficultyPreset"], json!("hard")); } #[test] fn optional_fields_are_omitted_when_absent() { let draft = BarkBattleDraftConfig::default(); let payload = serde_json::to_value(draft).expect("draft should serialize"); assert!(!payload.as_object().unwrap().contains_key("workId")); assert!(!payload.as_object().unwrap().contains_key("configVersion")); assert!(!payload.as_object().unwrap().contains_key("rulesetVersion")); assert!(!payload.as_object().unwrap().contains_key("description")); assert!( !payload .as_object() .unwrap() .contains_key("playerCharacterImageSrc") ); assert!( !payload .as_object() .unwrap() .contains_key("uiBackgroundImageSrc") ); let response = BarkBattlePersonalHistoryResponse { work_id: None, difficulty_preset: None, items: Vec::new(), best_summary: None, updated_at: "2026-05-13T11:00:00Z".to_string(), }; let payload = serde_json::to_value(response).expect("history response should serialize"); assert!(!payload.as_object().unwrap().contains_key("workId")); assert!( !payload .as_object() .unwrap() .contains_key("difficultyPreset") ); assert!(!payload.as_object().unwrap().contains_key("bestSummary")); } #[test] fn draft_config_serializes_persistent_identity_fields() { let draft = BarkBattleDraftConfig { draft_id: "bark-battle-draft-1".to_string(), work_id: Some("bark-battle-work-1".to_string()), config_version: Some(2), ruleset_version: Some("bark-battle-ruleset-v1".to_string()), title: "汪汪测试杯".to_string(), description: None, theme_description: "阳光草坪".to_string(), player_image_description: "主角".to_string(), opponent_image_description: "对手".to_string(), onomatopoeia: None, player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, difficulty_preset: BarkBattleDifficultyPreset::Normal, updated_at: "2026-05-14T10:00:00.000Z".to_string(), }; let payload = serde_json::to_value(draft).expect("draft should serialize"); assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); assert_eq!(payload["workId"], json!("bark-battle-work-1")); assert_eq!(payload["configVersion"], json!(2)); assert_eq!(payload["rulesetVersion"], json!("bark-battle-ruleset-v1")); } #[test] fn draft_config_update_request_serializes_generated_assets() { let update = BarkBattleDraftConfigUpdateRequest { draft_id: "bark-battle-draft-1".to_string(), work_id: Some("BB-12345678".to_string()), config_version: Some(2), ruleset_version: Some("bark-battle-ruleset-v1".to_string()), title: "汪汪测试杯".to_string(), description: None, theme_description: "阳光草坪".to_string(), player_image_description: "主角".to_string(), opponent_image_description: "对手".to_string(), onomatopoeia: Some(vec!["炸场!".to_string(), "破阵!".to_string()]), player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), opponent_character_image_src: Some("/generated-bark-battle/opponent.png".to_string()), ui_background_image_src: Some("/generated-bark-battle/background.png".to_string()), difficulty_preset: BarkBattleDifficultyPreset::Normal, }; let payload = serde_json::to_value(update).expect("draft update should serialize"); assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); assert_eq!(payload["workId"], json!("BB-12345678")); assert_eq!( payload["playerCharacterImageSrc"], json!("/generated-bark-battle/player.png") ); assert_eq!( payload["opponentCharacterImageSrc"], json!("/generated-bark-battle/opponent.png") ); assert_eq!( payload["uiBackgroundImageSrc"], json!("/generated-bark-battle/background.png") ); assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); assert!( !payload .as_object() .unwrap() .contains_key("leaderboardEnabled") ); } #[test] fn image_generation_request_uses_dedicated_asset_slot_and_result_prompt() { let request = BarkBattleImageAssetGenerateRequest { slot: BarkBattleAssetSlot::OpponentCharacter, draft_id: Some("bark-battle-draft-1".to_string()), config: BarkBattleConfigEditorPayload { title: "汪汪冠军杯".to_string(), description: Some(String::new()), theme_description: "霓虹公园擂台".to_string(), player_image_description: "红围巾柴犬".to_string(), opponent_image_description: "蓝头带哈士奇".to_string(), onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, difficulty_preset: BarkBattleDifficultyPreset::Normal, }, }; let payload = serde_json::to_value(request).expect("request should serialize"); assert_eq!(payload["slot"], json!("opponent-character")); assert_eq!( payload["config"]["opponentImageDescription"], json!("蓝头带哈士奇") ); let response = BarkBattleGeneratedImageAsset { image_src: "/generated-bark-battle-assets/draft/opponent/image.webp".to_string(), asset_id: "asset-1".to_string(), source_type: Some("generated".to_string()), model: "gpt-image-2".to_string(), size: "1024*1024".to_string(), task_id: "task-1".to_string(), prompt: "后端拼装后的对手形象 prompt".to_string(), actual_prompt: None, }; let payload = serde_json::to_value(response).expect("response should serialize"); assert_eq!( payload["imageSrc"], json!("/generated-bark-battle-assets/draft/opponent/image.webp") ); assert_eq!(payload["prompt"], json!("后端拼装后的对手形象 prompt")); } #[test] fn replacement_sources_serialize_as_camel_case_config_fields() { let config = BarkBattleConfigEditorPayload { title: "周末狗狗杯".to_string(), description: Some("轻配置草稿".to_string()), theme_description: "霓虹公园".to_string(), player_image_description: "柴犬主角".to_string(), opponent_image_description: "哈士奇对手".to_string(), onomatopoeia: Some(vec!["轰汪!".to_string(), "冲啊!".to_string()]), player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), difficulty_preset: BarkBattleDifficultyPreset::Hard, }; let payload = serde_json::to_value(config).expect("config should serialize"); assert_eq!( payload["playerCharacterImageSrc"], json!("/generated-bark-battle/player.png") ); assert_eq!( payload["opponentCharacterImageSrc"], json!("https://example.test/opponent.png") ); assert_eq!( payload["uiBackgroundImageSrc"], json!("/generated-bark-battle/ui.png") ); assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); } #[test] fn finish_response_serializes_player_win_and_accepted() { let response = BarkBattleRunFinishResponse { status: BarkBattleFinishStatus::Accepted, run_id: "run-1".to_string(), work_id: "work-1".to_string(), config_version: 3, ruleset_version: "bark-battle-ruleset-v1".to_string(), difficulty_preset: BarkBattleDifficultyPreset::Normal, server_result: BarkBattleServerResult::PlayerWin, score_summary: BarkBattleScoreSummary { duration_ms: 30_000, trigger_count: 12, max_volume: 0.95, average_volume: 0.62, final_energy: 88.5, combo_max: 7, }, leaderboard_score: Some(98_765), anti_cheat_flags: Vec::new(), updated_at: "2026-05-13T11:00:00Z".to_string(), }; let payload = serde_json::to_value(response).expect("finish response should serialize"); assert_eq!(payload["runId"], json!("run-1")); assert_eq!(payload["status"], json!("accepted")); assert_eq!(payload["serverResult"], json!("player_win")); assert_eq!(payload["leaderboardScore"], json!(98_765)); assert_eq!(payload["scoreSummary"]["finalEnergy"], json!(88.5)); } #[test] fn work_stats_fields_are_constructible() { let stats = BarkBattleWorkStats { work_id: "work-1".to_string(), config_version: Some(3), ruleset_version: "bark-battle-ruleset-v1".to_string(), difficulty_preset: BarkBattleDifficultyPreset::Normal, play_start_count: 10, finish_count: 9, win_count: 5, draw_count: 2, loss_count: 2, flagged_count: 1, leaderboard_entry_count: 4, best_leaderboard_score: Some(98_765), best_final_energy: Some(97.5), average_final_energy: Some(73.25), updated_at: "2026-05-13T11:00:00Z".to_string(), }; assert_eq!(stats.work_id, "work-1"); assert_eq!(stats.play_start_count, 10); assert_eq!(stats.finish_count, 9); assert_eq!(stats.win_count, 5); assert_eq!(stats.draw_count, 2); assert_eq!(stats.loss_count, 2); assert_eq!(stats.flagged_count, 1); assert_eq!(stats.leaderboard_entry_count, 4); assert_eq!(stats.best_leaderboard_score, Some(98_765)); assert_eq!(stats.best_final_energy, Some(97.5)); assert_eq!(stats.average_final_energy, Some(73.25)); assert_eq!(stats.updated_at, "2026-05-13T11:00:00Z"); } }