feat: complete bark battle draft publish flow

This commit is contained in:
2026-05-19 15:27:50 +08:00
parent 804f1e32be
commit 23fb895e82
24 changed files with 1710 additions and 159 deletions

View File

@@ -21,8 +21,9 @@ use shared_kernel::{
offset_datetime_to_unix_micros, parse_rfc3339,
};
use spacetime_client::{
BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord,
BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
BarkBattleWorkPublishRecordInput, SpacetimeClientError,
};
use time::{Duration as TimeDuration, OffsetDateTime};
@@ -73,11 +74,8 @@ struct BarkBattleRunSnapshotRecord {
#[serde(rename_all = "camelCase")]
struct BarkBattleDraftConfigSnapshotRecord {
draft_id: String,
#[allow(dead_code)]
work_id: String,
#[allow(dead_code)]
config_version: u64,
#[allow(dead_code)]
ruleset_version: String,
#[serde(default)]
config_json: String,
@@ -105,6 +103,35 @@ pub async fn create_bark_battle_draft(
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
let now = current_utc_micros();
let editor_config = BarkBattleConfigEditorPayload {
title: payload.title.clone(),
description: payload.description.clone(),
theme_preset: payload.theme_preset.clone(),
player_dog_skin_preset: payload.player_dog_skin_preset.clone(),
opponent_dog_skin_preset: payload.opponent_dog_skin_preset.clone(),
player_character_image_src: normalize_optional_bark_battle_asset_source(
&request_context,
payload.player_character_image_src.as_deref(),
"playerCharacterImageSrc",
)?,
opponent_character_image_src: normalize_optional_bark_battle_asset_source(
&request_context,
payload.opponent_character_image_src.as_deref(),
"opponentCharacterImageSrc",
)?,
ui_background_image_src: normalize_optional_bark_battle_asset_source(
&request_context,
payload.ui_background_image_src.as_deref(),
"uiBackgroundImageSrc",
)?,
bark_sound_src: normalize_optional_bark_battle_asset_source(
&request_context,
payload.bark_sound_src.as_deref(),
"barkSoundSrc",
)?,
difficulty_preset: payload.difficulty_preset.clone(),
leaderboard_enabled: payload.leaderboard_enabled,
};
let draft = state
.spacetime_client()
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
@@ -127,7 +154,35 @@ pub async fn create_bark_battle_draft(
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let draft = map_draft_config_record(draft, &request_context)?;
let draft_snapshot = parse_draft_snapshot_record(draft, &request_context)?;
let config_json = serde_json::to_string(&editor_config).map_err(|error| {
bark_battle_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle config JSON 序列化失败: {error}"),
})),
)
})?;
let updated = state
.spacetime_client()
.update_bark_battle_draft_config(BarkBattleDraftConfigUpsertRecordInput {
draft_id: draft_snapshot.draft_id,
owner_user_id: authenticated.claims().user_id().to_string(),
work_id: draft_snapshot.work_id,
config_version: draft_snapshot.config_version.saturating_add(1),
ruleset_version: draft_snapshot.ruleset_version,
difficulty_preset: difficulty_to_spacetime_string(&editor_config.difficulty_preset)
.to_string(),
leaderboard_enabled: editor_config.leaderboard_enabled,
config_json,
updated_at_micros: now,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let draft = map_draft_config_record(updated, &request_context)?;
Ok(json_success_body(Some(&request_context), draft))
}
@@ -139,13 +194,17 @@ pub async fn publish_bark_battle_work(
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
let work_id = payload
let Some(work_id) = payload
.work_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX));
.map(ToString::to_string) else {
return Err(bark_battle_bad_request(
&request_context,
"workId 缺失,请重新生成草稿后再发布。",
));
};
let published_snapshot_json = payload
.published_snapshot
.as_ref()
@@ -473,11 +532,18 @@ fn map_draft_config_record(
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
Ok(BarkBattleDraftConfig {
draft_id: snapshot.draft_id,
work_id: Some(snapshot.work_id),
config_version: Some(snapshot.config_version.min(u64::from(u32::MAX)) as u32),
ruleset_version: Some(snapshot.ruleset_version),
title: editor_config.title,
description: editor_config.description,
theme_preset: editor_config.theme_preset,
player_dog_skin_preset: editor_config.player_dog_skin_preset,
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
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,
bark_sound_src: editor_config.bark_sound_src,
difficulty_preset: editor_config.difficulty_preset,
leaderboard_enabled: editor_config.leaderboard_enabled,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
@@ -505,6 +571,10 @@ fn map_runtime_config_record(
theme_preset: editor_config.theme_preset,
player_dog_skin_preset: editor_config.player_dog_skin_preset,
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
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,
bark_sound_src: editor_config.bark_sound_src,
leaderboard_enabled: editor_config.leaderboard_enabled,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
})
@@ -527,6 +597,10 @@ fn map_published_config_record(
theme_preset: editor_config.theme_preset,
player_dog_skin_preset: editor_config.player_dog_skin_preset,
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
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,
bark_sound_src: editor_config.bark_sound_src,
difficulty_preset: editor_config.difficulty_preset,
leaderboard_enabled: editor_config.leaderboard_enabled,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
@@ -592,6 +666,23 @@ fn ensure_non_empty(
Ok(())
}
fn normalize_optional_bark_battle_asset_source(
request_context: &RequestContext,
value: Option<&str>,
field_name: &str,
) -> Result<Option<String>, Response> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.chars().count() > 512 {
return Err(bark_battle_bad_request(
request_context,
&format!("{field_name} 不能超过 512 个字符"),
));
}
Ok(Some(value.to_string()))
}
fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response {
bark_battle_error_response(
request_context,
@@ -753,6 +844,7 @@ fn format_rfc3339_or_timestamp_micros(micros: i64) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn unit_and_energy_are_clamped_to_spacetime_millis() {
@@ -773,4 +865,43 @@ mod tests {
1_713_672_001_234_567
);
}
#[test]
fn draft_config_mapping_includes_stable_work_identity() {
let request_context = RequestContext::new(
"test-request".to_string(),
"POST /api/creation/bark-battle/drafts".to_string(),
Duration::ZERO,
false,
);
let config_json = json!({
"title": "汪汪测试杯",
"description": "",
"themePreset": "sunny-yard",
"playerDogSkinPreset": "主角",
"opponentDogSkinPreset": "对手",
"difficultyPreset": "normal",
"leaderboardEnabled": true
})
.to_string();
let row = json!({
"draftId": "bark-battle-draft-1",
"workId": "bark-battle-work-1",
"configVersion": 2,
"rulesetVersion": "bark-battle-ruleset-v1",
"configJson": config_json,
"updatedAtMicros": 1_713_686_401_234_567i64,
});
let draft = map_draft_config_record(row, &request_context)
.expect("draft config should map from SpacetimeDB snapshot");
assert_eq!(draft.draft_id, "bark-battle-draft-1");
assert_eq!(draft.work_id.as_deref(), Some("bark-battle-work-1"));
assert_eq!(draft.config_version, Some(2));
assert_eq!(
draft.ruleset_version.as_deref(),
Some("bark-battle-ruleset-v1")
);
}
}