Files
Genarrative/server-rs/crates/shared-contracts/src/bark_battle.rs
2026-05-22 05:00:07 +08:00

964 lines
38 KiB
Rust

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<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>,
}
#[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<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,
}
#[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<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<BarkBattleDraftCreateRequest> 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<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,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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 {
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,
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<String>,
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<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 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<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 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 {
pub work_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_route: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_runtime_version: Option<String>,
}
#[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<BarkBattleServerResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_runtime_version: Option<String>,
}
#[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<u64>,
#[serde(default)]
pub anti_cheat_flags: Vec<String>,
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<u32>,
pub ruleset_version: String,
pub difficulty_preset: BarkBattleDifficultyPreset,
#[serde(default)]
pub entries: Vec<BarkBattleLeaderboardEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub viewer_best: Option<BarkBattleLeaderboardEntry>,
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<u64>,
#[serde(default)]
pub anti_cheat_flags: Vec<String>,
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<u32>,
pub ruleset_version: String,
pub difficulty_preset: BarkBattleDifficultyPreset,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub best_leaderboard_score: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub best_final_energy: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub best_trigger_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub best_max_volume: Option<f32>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub difficulty_preset: Option<BarkBattleDifficultyPreset>,
#[serde(default)]
pub items: Vec<BarkBattlePersonalHistoryItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub best_summary: Option<BarkBattlePersonalBestSummary>,
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<u32>,
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<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub best_final_energy: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub average_final_energy: Option<f32>,
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");
}
}