fix: polish bark battle creation flow

This commit is contained in:
kdletters
2026-05-22 05:00:07 +08:00
parent 01da85a577
commit bf82f04b64
73 changed files with 9362 additions and 2663 deletions

View File

@@ -30,6 +30,14 @@ pub enum BarkBattleFinishStatus {
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 {
@@ -39,8 +47,6 @@ pub struct BarkBattleReplacementConfig {
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -49,20 +55,19 @@ pub struct BarkBattleConfigEditorPayload {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub theme_preset: String,
pub player_dog_skin_preset: String,
pub opponent_dog_skin_preset: 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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
#[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -71,20 +76,19 @@ pub struct BarkBattleDraftCreateRequest {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub theme_preset: String,
pub player_dog_skin_preset: String,
pub opponent_dog_skin_preset: 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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
#[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
}
impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload {
@@ -92,15 +96,59 @@ impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload {
Self {
title: value.title,
description: value.description,
theme_preset: value.theme_preset,
player_dog_skin_preset: value.player_dog_skin_preset,
opponent_dog_skin_preset: value.opponent_dog_skin_preset,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ruleset_version: Option<String>,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset,
}
impl From<BarkBattleDraftConfigUpdateRequest> 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,
bark_sound_src: value.bark_sound_src,
difficulty_preset: value.difficulty_preset,
leaderboard_enabled: value.leaderboard_enabled,
}
}
}
@@ -115,6 +163,30 @@ pub struct BarkBattleWorkPublishRequest {
pub published_snapshot: Option<BarkBattleConfigEditorPayload>,
}
#[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<String>,
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<String>,
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<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleDraftConfig {
@@ -128,20 +200,19 @@ pub struct BarkBattleDraftConfig {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub theme_preset: String,
pub player_dog_skin_preset: String,
pub opponent_dog_skin_preset: 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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
#[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
pub updated_at: String,
}
@@ -154,15 +225,14 @@ impl Default for BarkBattleDraftConfig {
ruleset_version: None,
title: String::new(),
description: None,
theme_preset: String::new(),
player_dog_skin_preset: String::new(),
opponent_dog_skin_preset: String::new(),
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,
bark_sound_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal,
leaderboard_enabled: true,
updated_at: String::new(),
}
}
@@ -180,19 +250,18 @@ pub struct BarkBattlePublishedConfig {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub theme_preset: String,
pub player_dog_skin_preset: String,
pub opponent_dog_skin_preset: 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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
pub updated_at: String,
pub published_at: String,
}
@@ -210,21 +279,75 @@ pub struct BarkBattleRuntimeConfig {
pub draw_threshold: f32,
pub min_bark_gap_ms: u64,
pub difficulty_preset: BarkBattleDifficultyPreset,
pub theme_preset: String,
pub player_dog_skin_preset: String,
pub opponent_dog_skin_preset: 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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
pub leaderboard_enabled: bool,
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<String>,
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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
pub difficulty_preset: BarkBattleDifficultyPreset,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_status: Option<String>,
pub publish_ready: bool,
pub play_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub win_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draw_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loss_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recent_play_count_7d: Option<u64>,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleWorksResponse {
#[serde(default)]
pub items: Vec<BarkBattleWorkSummary>,
}
#[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 {
@@ -425,6 +548,115 @@ 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();
@@ -523,15 +755,14 @@ mod tests {
ruleset_version: Some("bark-battle-ruleset-v1".to_string()),
title: "汪汪测试杯".to_string(),
description: None,
theme_preset: "sunny-yard".to_string(),
player_dog_skin_preset: "主角".to_string(),
opponent_dog_skin_preset: "对手".to_string(),
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,
bark_sound_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal,
leaderboard_enabled: true,
updated_at: "2026-05-14T10:00:00.000Z".to_string(),
};
@@ -540,10 +771,96 @@ mod tests {
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["rulesetVersion"],
json!("bark-battle-ruleset-v1")
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]
@@ -551,15 +868,14 @@ mod tests {
let config = BarkBattleConfigEditorPayload {
title: "周末狗狗杯".to_string(),
description: Some("轻配置草稿".to_string()),
theme_preset: "neon-park".to_string(),
player_dog_skin_preset: "shiba".to_string(),
opponent_dog_skin_preset: "husky".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()),
bark_sound_src: Some("/generated-bark-battle/bark.mp3".to_string()),
difficulty_preset: BarkBattleDifficultyPreset::Hard,
leaderboard_enabled: true,
};
let payload = serde_json::to_value(config).expect("config should serialize");
@@ -576,10 +892,7 @@ mod tests {
payload["uiBackgroundImageSrc"],
json!("/generated-bark-battle/ui.png")
);
assert_eq!(
payload["barkSoundSrc"],
json!("/generated-bark-battle/bark.mp3")
);
assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc"));
}
#[test]