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

@@ -1,6 +1,7 @@
use crate::*;
use serde::{Serialize, de::DeserializeOwned};
use sha2::{Digest, Sha256};
use spacetimedb::AnonymousViewContext;
pub(crate) mod tables;
mod types;
@@ -8,6 +9,38 @@ mod types;
pub use tables::*;
pub use types::*;
/// Bark Battle 公开广场列表投影。
///
/// HTTP gallery 订阅该 public view 后读取本地 cacheview 只从已发布配置和统计投影
/// 组装 v1 公开字段,避免每个公开列表请求重新调用 procedure 热路径。
#[spacetimedb::view(accessor = bark_battle_gallery_view, public)]
pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec<BarkBattleGalleryViewRow> {
let mut items = ctx
.db
.bark_battle_published_config()
.by_bark_battle_published_owner_user_id()
.filter(""..)
.filter_map(|row| match build_bark_battle_gallery_view_row(ctx, &row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"汪汪声浪公开广场 view 跳过损坏的作品投影 work_id={}: {}",
row.work_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.work_id.cmp(&right.work_id))
});
items
}
#[spacetimedb::procedure]
pub fn create_bark_battle_draft(
ctx: &mut ProcedureContext,
@@ -106,21 +139,23 @@ fn create_bark_battle_draft_tx(
let config = BarkBattleEditorConfigSnapshot {
title: normalize_title(input.title.as_deref())?,
description: normalize_optional_text(input.description.as_deref()),
theme_preset: normalize_required_preset(&input.theme_preset, "theme_preset")?,
player_dog_skin_preset: normalize_required_preset(
&input.player_dog_skin_preset,
"player_dog_skin_preset",
theme_description: normalize_required_description(
&input.theme_description,
"theme_description",
)?,
opponent_dog_skin_preset: normalize_required_preset(
&input.opponent_dog_skin_preset,
"opponent_dog_skin_preset",
player_image_description: normalize_required_description(
&input.player_image_description,
"player_image_description",
)?,
opponent_image_description: normalize_required_description(
&input.opponent_image_description,
"opponent_image_description",
)?,
onomatopoeia: Vec::new(),
player_character_image_src: None,
opponent_character_image_src: None,
ui_background_image_src: None,
bark_sound_src: None,
difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?,
leaderboard_enabled: input.leaderboard_enabled.unwrap_or(true),
};
let row = BarkBattleDraftConfigRow {
draft_id: input.draft_id.clone(),
@@ -129,7 +164,7 @@ fn create_bark_battle_draft_tx(
config_version: 1,
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
difficulty_preset: config.difficulty_preset.clone(),
leaderboard_enabled: config.leaderboard_enabled,
leaderboard_enabled: true,
config_json: to_json_string(&config),
editor_state_json: normalize_json_string(
input.editor_state_json.as_deref(),
@@ -151,10 +186,8 @@ fn update_bark_battle_draft_config_tx(
require_non_empty(&input.work_id, "bark_battle work_id")?;
let mut editor_config = parse_editor_config(&input.config_json)?;
normalize_editor_config_snapshot(&mut editor_config)?;
if editor_config.difficulty_preset != input.difficulty_preset
|| editor_config.leaderboard_enabled != input.leaderboard_enabled
{
return Err("bark_battle config_json 与行字段不一致".to_string());
if editor_config.difficulty_preset != input.difficulty_preset {
return Err("bark_battle config_json 与 difficulty_preset 不匹配".to_string());
}
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let existing = ctx
@@ -166,14 +199,14 @@ fn update_bark_battle_draft_config_tx(
if existing.owner_user_id != input.owner_user_id || existing.work_id != input.work_id {
return Err("bark_battle draft owner/work 不匹配".to_string());
}
if input.config_version <= existing.config_version {
return Err("bark_battle draft config_version 必须递增".to_string());
}
let mut row = existing;
row.config_version = input.config_version;
// 中文注释HTTP BFF 会先读缓存再发更新,订阅缓存可能短暂落后;
// 这里按“至少递增 1”兜底避免前端重复保存素材时被版本号误伤。
row.config_version = input
.config_version
.max(row.config_version.saturating_add(1));
row.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?;
row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?;
row.leaderboard_enabled = input.leaderboard_enabled;
row.config_json = to_json_string(&editor_config);
row.updated_at = updated_at;
ctx.db
@@ -200,6 +233,20 @@ fn publish_bark_battle_work_tx(
if draft.owner_user_id != input.owner_user_id || draft.work_id != input.work_id {
return Err("bark_battle draft owner/work 不匹配".to_string());
}
let published_snapshot_json = match input.published_snapshot_json.as_deref() {
Some(value) => {
let mut editor_config = parse_editor_config(value)?;
normalize_editor_config_snapshot(&mut editor_config)?;
if editor_config.difficulty_preset != draft.difficulty_preset {
return Err(
"bark_battle published_snapshot_json 与草稿 difficulty_preset 不匹配"
.to_string(),
);
}
to_json_string(&editor_config)
}
None => draft.config_json.clone(),
};
let published = BarkBattlePublishedConfigRow {
work_id: draft.work_id.clone(),
owner_user_id: draft.owner_user_id.clone(),
@@ -207,12 +254,9 @@ fn publish_bark_battle_work_tx(
config_version: draft.config_version,
ruleset_version: normalize_ruleset_version(&draft.ruleset_version)?,
difficulty_preset: normalize_difficulty(Some(&draft.difficulty_preset))?,
leaderboard_enabled: draft.leaderboard_enabled,
config_json: draft.config_json.clone(),
published_snapshot_json: match input.published_snapshot_json.as_deref() {
Some(value) => normalize_json_string(Some(value), "published_snapshot_json")?,
None => draft.config_json.clone(),
},
leaderboard_enabled: true,
config_json: published_snapshot_json.clone(),
published_snapshot_json,
created_at: published_at,
updated_at: published_at,
published_at,
@@ -297,7 +341,7 @@ fn start_bark_battle_run_tx(
config_version: input.config_version,
ruleset_version: input.ruleset_version,
difficulty_preset: input.difficulty_preset,
leaderboard_enabled: published.leaderboard_enabled,
leaderboard_enabled: true,
status: BARK_BATTLE_RUN_RUNNING.to_string(),
client_started_at_micros: input.client_started_at_micros,
server_started_at: started_at,
@@ -483,7 +527,6 @@ fn draft_snapshot(row: &BarkBattleDraftConfigRow) -> BarkBattleDraftConfigSnapsh
config_version: row.config_version,
ruleset_version: row.ruleset_version.clone(),
difficulty_preset: row.difficulty_preset.clone(),
leaderboard_enabled: row.leaderboard_enabled,
config_json: row.config_json.clone(),
editor_state_json: row.editor_state_json.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
@@ -499,7 +542,6 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt
config_version: row.config_version,
ruleset_version: row.ruleset_version.clone(),
difficulty_preset: row.difficulty_preset.clone(),
leaderboard_enabled: row.leaderboard_enabled,
config_json: row.config_json.clone(),
published_snapshot_json: row.published_snapshot_json.clone(),
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
@@ -507,6 +549,43 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt
}
}
fn build_bark_battle_gallery_view_row(
ctx: &AnonymousViewContext,
row: &BarkBattlePublishedConfigRow,
) -> Result<BarkBattleGalleryViewRow, String> {
let mut editor_config = parse_editor_config(&row.config_json)?;
normalize_editor_config_snapshot(&mut editor_config)?;
let stats = ctx
.db
.bark_battle_work_stats_projection()
.work_id()
.find(&row.work_id);
Ok(BarkBattleGalleryViewRow {
work_id: row.work_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_draft_id: row.source_draft_id.clone(),
config_version: row.config_version,
ruleset_version: row.ruleset_version.clone(),
difficulty_preset: row.difficulty_preset.clone(),
title: editor_config.title,
description: editor_config.description,
theme_description: editor_config.theme_description,
player_image_description: editor_config.player_image_description,
opponent_image_description: editor_config.opponent_image_description,
onomatopoeia: editor_config.onomatopoeia,
player_character_image_src: editor_config.player_character_image_src,
opponent_character_image_src: editor_config.opponent_character_image_src,
ui_background_image_src: editor_config.ui_background_image_src,
play_count: stats.as_ref().map(|stats| stats.play_count).unwrap_or(0),
finish_count: stats
.as_ref()
.map(|stats| stats.finished_count)
.unwrap_or(0),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
})
}
fn hash_run_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect()
@@ -530,11 +609,17 @@ fn normalize_editor_config_snapshot(
config: &mut BarkBattleEditorConfigSnapshot,
) -> Result<(), String> {
config.title = normalize_title(Some(&config.title))?;
config.theme_preset = normalize_required_preset(&config.theme_preset, "theme_preset")?;
config.player_dog_skin_preset =
normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?;
config.opponent_dog_skin_preset =
normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?;
config.theme_description =
normalize_required_description(&config.theme_description, "theme_description")?;
config.player_image_description = normalize_required_description(
&config.player_image_description,
"player_image_description",
)?;
config.opponent_image_description = normalize_required_description(
&config.opponent_image_description,
"opponent_image_description",
)?;
config.onomatopoeia = normalize_onomatopoeia(std::mem::take(&mut config.onomatopoeia));
config.player_character_image_src = normalize_optional_asset_source(
config.player_character_image_src.as_deref(),
"player_character_image_src",
@@ -547,8 +632,6 @@ fn normalize_editor_config_snapshot(
config.ui_background_image_src.as_deref(),
"ui_background_image_src",
)?;
config.bark_sound_src =
normalize_optional_asset_source(config.bark_sound_src.as_deref(), "bark_sound_src")?;
config.difficulty_preset = normalize_difficulty(Some(&config.difficulty_preset))?;
Ok(())
}
@@ -568,12 +651,24 @@ fn normalize_optional_text(value: Option<&str>) -> String {
value.unwrap_or_default().trim().chars().take(120).collect()
}
fn normalize_required_preset(value: &str, field_name: &str) -> Result<String, String> {
let preset = value.trim();
if preset.is_empty() {
fn normalize_required_description(value: &str, field_name: &str) -> Result<String, String> {
let description = value.trim();
if description.is_empty() {
return Err(format!("bark_battle {field_name} 不能为空"));
}
Ok(preset.to_string())
if description.chars().count() > 240 {
return Err(format!("bark_battle {field_name} 不能超过 240 个字符"));
}
Ok(description.to_string())
}
fn normalize_onomatopoeia(words: Vec<String>) -> Vec<String> {
words
.into_iter()
.map(|word| word.trim().chars().take(12).collect::<String>())
.filter(|word| !word.is_empty())
.take(24)
.collect()
}
fn normalize_optional_asset_source(
@@ -674,7 +769,6 @@ fn run_snapshot(row: &BarkBattleRuntimeRunRow) -> BarkBattleRunSnapshot {
config_version: row.config_version,
ruleset_version: row.ruleset_version.clone(),
difficulty_preset: row.difficulty_preset.clone(),
leaderboard_enabled: row.leaderboard_enabled,
status: row.status.clone(),
client_started_at_micros: row.client_started_at_micros,
server_started_at_micros: row.server_started_at.to_micros_since_unix_epoch(),
@@ -905,7 +999,6 @@ mod tests {
config_version: 1,
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
difficulty_preset: BARK_BATTLE_DIFFICULTY_NORMAL.to_string(),
leaderboard_enabled: true,
config_json: "{}".to_string(),
updated_at_micros: 1_700_000,
};
@@ -919,7 +1012,6 @@ mod tests {
config_version: input.config_version,
ruleset_version: input.ruleset_version.clone(),
difficulty_preset: input.difficulty_preset.clone(),
leaderboard_enabled: input.leaderboard_enabled,
config_json: input.config_json.clone(),
editor_state_json: "{}".to_string(),
created_at_micros: 1_700_000,
@@ -945,4 +1037,84 @@ mod tests {
assert!(normalize_title(Some(" 标题 ")).is_ok());
assert!(normalize_title(Some(" ")).is_err());
}
#[test]
fn published_snapshot_is_normalized_as_runtime_config() {
let mut editor_config = parse_editor_config(
&serde_json::json!({
"title": " 汪汪测试杯 ",
"description": "",
"themeDescription": " 阳光草坪 ",
"playerImageDescription": " 主角柴犬 ",
"opponentImageDescription": " 对手哈士奇 ",
"onomatopoeia": [" 轰汪! ", "冲啊冲啊冲啊冲啊冲啊!", ""],
"playerCharacterImageSrc": "/generated-bark-battle-assets/player.png",
"opponentCharacterImageSrc": "/generated-bark-battle-assets/opponent.png",
"uiBackgroundImageSrc": "/generated-bark-battle-assets/ui.png",
"difficultyPreset": "normal"
})
.to_string(),
)
.expect("published snapshot should parse");
normalize_editor_config_snapshot(&mut editor_config)
.expect("published snapshot should normalize");
let config_json = to_json_string(&editor_config);
assert!(config_json.contains("/generated-bark-battle-assets/player.png"));
assert!(config_json.contains("/generated-bark-battle-assets/opponent.png"));
assert!(config_json.contains("/generated-bark-battle-assets/ui.png"));
assert!(config_json.contains("阳光草坪"));
assert!(config_json.contains("轰汪!"));
assert!(config_json.contains("冲啊冲啊冲啊冲啊"));
assert!(!config_json.contains("冲啊冲啊冲啊冲啊冲啊!"));
assert!(!config_json.contains("\"title\":\" 汪汪测试杯 \""));
assert!(!config_json.contains("themePreset"));
assert!(!config_json.contains("playerDogSkinPreset"));
assert!(!config_json.contains("opponentDogSkinPreset"));
}
#[test]
fn bark_battle_gallery_view_row_exposes_custom_onomatopoeia() {
let mut editor_config = parse_editor_config(
&serde_json::json!({
"title": "声浪公开赛",
"description": "画廊映射测试",
"themeDescription": "霓虹竞技场",
"playerImageDescription": "星际猫骑士",
"opponentImageDescription": "机器人拳手",
"onomatopoeia": [" 轰! ", "炸场!", ""],
"difficultyPreset": "normal"
})
.to_string(),
)
.expect("gallery config should parse");
normalize_editor_config_snapshot(&mut editor_config)
.expect("gallery config should normalize");
let row = BarkBattleGalleryViewRow {
work_id: "BB-33333333".to_string(),
owner_user_id: "user-3".to_string(),
source_draft_id: Some("bark-battle-draft-3".to_string()),
config_version: 1,
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
difficulty_preset: editor_config.difficulty_preset.clone(),
title: editor_config.title,
description: editor_config.description,
theme_description: editor_config.theme_description,
player_image_description: editor_config.player_image_description,
opponent_image_description: editor_config.opponent_image_description,
onomatopoeia: editor_config.onomatopoeia,
player_character_image_src: editor_config.player_character_image_src,
opponent_character_image_src: editor_config.opponent_character_image_src,
ui_background_image_src: editor_config.ui_background_image_src,
play_count: 8,
finish_count: 5,
updated_at_micros: 1_713_686_401_234_567,
published_at_micros: 1_713_686_401_234_000,
};
assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]);
}
}

View File

@@ -24,11 +24,10 @@ pub struct BarkBattleDraftCreateInput {
pub work_id: String,
pub title: Option<String>,
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,
pub difficulty_preset: Option<String>,
pub leaderboard_enabled: Option<bool>,
pub editor_state_json: Option<String>,
pub created_at_micros: i64,
}
@@ -41,7 +40,6 @@ pub struct BarkBattleDraftConfigUpsertInput {
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String,
pub updated_at_micros: i64,
}
@@ -116,19 +114,18 @@ pub struct BarkBattleProcedureResult {
pub struct BarkBattleEditorConfigSnapshot {
pub title: String,
pub description: 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 = "Vec::is_empty")]
pub onomatopoeia: 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: String,
pub leaderboard_enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
@@ -140,7 +137,6 @@ pub struct BarkBattleDraftConfigSnapshot {
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String,
pub editor_state_json: String,
pub created_at_micros: i64,
@@ -156,7 +152,6 @@ pub struct BarkBattleRuntimeConfigSnapshot {
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String,
pub published_snapshot_json: String,
pub published_at_micros: i64,
@@ -172,7 +167,6 @@ pub struct BarkBattleRunSnapshot {
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub status: String,
pub client_started_at_micros: i64,
pub server_started_at_micros: i64,
@@ -185,3 +179,31 @@ pub struct BarkBattleRunSnapshot {
pub leaderboard_score: Option<u64>,
pub score_id: Option<String>,
}
/// Bark Battle 公开广场只读投影行。
///
/// 该结构只暴露 v1 公共卡片需要的配置和基础统计,不把内部排行榜开关或旧皮肤 /
/// 音效预设重新带回公开语义。
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleGalleryViewRow {
pub work_id: String,
pub owner_user_id: String,
pub source_draft_id: Option<String>,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub title: String,
pub description: String,
pub theme_description: String,
pub player_image_description: String,
pub opponent_image_description: String,
pub onomatopoeia: Vec<String>,
pub player_character_image_src: Option<String>,
pub opponent_character_image_src: Option<String>,
pub ui_background_image_src: Option<String>,
pub play_count: u64,
pub finish_count: u64,
pub updated_at_micros: i64,
pub published_at_micros: i64,
}

View File

@@ -180,17 +180,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
}
migrate_visual_novel_entry_from_old_visible_default(ctx, now);
migrate_coming_soon_entry_from_old_open_default(
ctx,
now,
ComingSoonEntryDefault {
id: "bark-battle",
title: "汪汪声浪",
subtitle: "声控对战挑战",
image_src: "/creation-type-references/creative-agent.webp",
sort_order: 85,
},
);
migrate_bark_battle_entry_to_open_default(ctx, now);
migrate_coming_soon_entry_from_old_open_default(
ctx,
now,
@@ -204,6 +194,36 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
);
}
fn migrate_bark_battle_entry_to_open_default(ctx: &ReducerContext, now: Timestamp) {
let id = "bark-battle".to_string();
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
return;
};
// 中文注释:只纠偏系统默认汪汪声浪入口,不覆盖后台手动改过标题、排序或可见性的配置。
let still_system_default = row.title == "汪汪声浪"
&& row.subtitle == "声控对战挑战"
&& row.visible
&& row.sort_order == 85
&& (row.image_src == "/creation-type-references/creative-agent.webp"
|| row.image_src == "/creation-type-references/bark-battle.webp")
&& ((row.badge == "敬请期待" && !row.open) || (row.badge == "可创建" && row.open));
if !still_system_default {
return;
}
ctx.db
.creation_entry_type_config()
.id()
.update(CreationEntryTypeConfig {
badge: "可创建".to_string(),
image_src: "/creation-type-references/bark-battle.webp".to_string(),
open: true,
updated_at: now,
..row
});
}
fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) {
let id = "visual-novel".to_string();
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {