Files
Genarrative/server-rs/crates/api-server/src/bark_battle.rs
高物 27b30f974b Update spacetime-client bindings and frontend
Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
2026-06-04 22:44:19 +08:00

1912 lines
73 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::Response,
};
use module_assets::{
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id,
};
use module_bark_battle::{BARK_BATTLE_RULESET_VERSION_V1, BarkBattleRuleset};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::bark_battle::{
BarkBattleAssetSlot, BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics,
BarkBattleDifficultyPreset, BarkBattleDraftConfig, BarkBattleDraftConfigUpdateRequest,
BarkBattleDraftCreateRequest, BarkBattleFinishStatus, BarkBattleGeneratedImageAsset,
BarkBattleImageAssetGenerateRequest, BarkBattlePublishedConfig, BarkBattleRunFinishRequest,
BarkBattleRunFinishResponse, BarkBattleRunStartRequest, BarkBattleRunStartResponse,
BarkBattleScoreSummary, BarkBattleServerResult, BarkBattleWorkPublishRequest,
BarkBattleWorkSummary, BarkBattleWorksResponse,
};
use shared_kernel::{
build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros,
offset_datetime_to_unix_micros, parse_rfc3339,
};
use spacetime_client::{
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
};
use time::{Duration as TimeDuration, OffsetDateTime};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken,
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
normalize_generated_image_asset_mime,
},
http_error::AppError,
openai_image_generation::{
GPT_IMAGE_2_MODEL, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const BARK_BATTLE_RUNTIME_PROVIDER: &str = "bark-battle-runtime";
const BARK_BATTLE_DRAFT_ID_PREFIX: &str = "bark-battle-draft-";
const BARK_BATTLE_WORK_ID_PREFIX: &str = "BB-";
const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-";
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024";
const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792";
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct BarkBattleRunSnapshotRecord {
run_id: String,
work_id: String,
config_version: u64,
ruleset_version: String,
difficulty_preset: String,
#[serde(default)]
client_started_at_micros: i64,
#[serde(default)]
server_started_at_micros: i64,
#[serde(default)]
server_finished_at_micros: Option<i64>,
#[serde(default)]
metrics_json: String,
#[serde(default)]
server_result: Option<String>,
#[serde(default)]
validation_status: String,
#[serde(default)]
anti_cheat_flags_json: String,
#[serde(default)]
leaderboard_score: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BarkBattleDraftConfigSnapshotRecord {
draft_id: String,
work_id: String,
config_version: u64,
ruleset_version: String,
#[serde(default)]
config_json: String,
updated_at_micros: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BarkBattleRuntimeConfigSnapshotRecord {
work_id: String,
source_draft_id: Option<String>,
config_version: u64,
ruleset_version: String,
#[serde(default)]
config_json: String,
published_at_micros: i64,
updated_at_micros: i64,
}
pub async fn create_bark_battle_draft(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleDraftCreateRequest>, JsonRejection>,
) -> 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_description: payload.theme_description.clone(),
player_image_description: payload.player_image_description.clone(),
opponent_image_description: payload.opponent_image_description.clone(),
onomatopoeia: payload.onomatopoeia.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",
)?,
difficulty_preset: payload.difficulty_preset.clone(),
};
let draft = state
.spacetime_client()
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
draft_id: build_prefixed_uuid_id(BARK_BATTLE_DRAFT_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
work_id: build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX),
title: Some(payload.title),
description: payload.description,
theme_description: payload.theme_description,
player_image_description: payload.player_image_description,
opponent_image_description: payload.opponent_image_description,
difficulty_preset: Some(
difficulty_to_spacetime_string(&payload.difficulty_preset).to_string(),
),
editor_state_json: Some("{}".to_string()),
created_at_micros: now,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let draft_snapshot = parse_draft_snapshot_record(draft, &request_context)?;
let config_json = serialize_bark_battle_editor_config(&request_context, &editor_config)?;
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(),
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))
}
pub async fn update_bark_battle_draft_config(
State(state): State<AppState>,
Path(draft_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleDraftConfigUpdateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &draft_id, "draftId")?;
let Json(payload) = bark_battle_json(payload, &request_context)?;
if payload.draft_id.trim() != draft_id {
return Err(bark_battle_bad_request(
&request_context,
"draftId 与路径参数不一致",
));
}
let Some(work_id) = payload
.work_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
else {
return Err(bark_battle_bad_request(
&request_context,
"workId 缺失,请重新生成草稿后再保存素材。",
));
};
let owner_user_id = authenticated.claims().user_id().to_string();
let now = current_utc_micros();
let next_config_version = payload
.config_version
.map(u64::from)
.unwrap_or(1)
.saturating_add(1);
let ruleset_version = payload
.ruleset_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(BARK_BATTLE_RULESET_VERSION_V1)
.to_string();
let editor_config = BarkBattleConfigEditorPayload {
title: payload.title,
description: payload.description,
theme_description: payload.theme_description,
player_image_description: payload.player_image_description,
opponent_image_description: payload.opponent_image_description,
onomatopoeia: payload.onomatopoeia,
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",
)?,
difficulty_preset: payload.difficulty_preset,
};
let config_json = serialize_bark_battle_editor_config(&request_context, &editor_config)?;
let updated = state
.spacetime_client()
.update_bark_battle_draft_config(BarkBattleDraftConfigUpsertRecordInput {
draft_id,
owner_user_id,
work_id,
config_version: next_config_version,
ruleset_version,
difficulty_preset: difficulty_to_spacetime_string(&editor_config.difficulty_preset)
.to_string(),
config_json,
updated_at_micros: now,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let mut draft = map_draft_config_record(updated, &request_context)?;
// 中文注释SpacetimeDB procedure 返回可能早于订阅缓存合并完成HTTP 回包先以本次请求
// 的 configJson 为准,避免前端拿到旧快照后误判“草稿没有素材”。
draft.player_character_image_src = editor_config.player_character_image_src;
draft.opponent_character_image_src = editor_config.opponent_character_image_src;
draft.ui_background_image_src = editor_config.ui_background_image_src;
Ok(json_success_body(Some(&request_context), draft))
}
pub async fn generate_bark_battle_image_asset(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleImageAssetGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let asset_id = build_prefixed_uuid_id(BARK_BATTLE_IMAGE_ID_PREFIX);
let prompt = build_bark_battle_image_prompt(&payload.slot, &payload.config);
let size = bark_battle_image_size(&payload.slot).to_string();
let slot = payload.slot.clone();
let draft_id = payload
.draft_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let result = execute_billable_asset_operation(
&state,
&owner_user_id,
bark_battle_slot_asset_kind(&slot),
asset_id.as_str(),
async {
generate_and_persist_bark_battle_image_asset(
&state,
&request_context,
&owner_user_id,
&slot,
draft_id.as_deref(),
asset_id.as_str(),
prompt.as_str(),
size.as_str(),
)
.await
},
)
.await
.map_err(|error| bark_battle_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), result))
}
pub async fn publish_bark_battle_work(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleWorkPublishRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
let Some(work_id) = payload
.work_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
else {
return Err(bark_battle_bad_request(
&request_context,
"workId 缺失,请重新生成草稿后再发布。",
));
};
let published_snapshot_json = payload
.published_snapshot
.as_ref()
.map(serde_json::to_string)
.transpose()
.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!("publishedSnapshot JSON 序列化失败: {error}"),
})),
)
})?;
let published = state
.spacetime_client()
.publish_bark_battle_work(BarkBattleWorkPublishRecordInput {
draft_id: payload.draft_id,
owner_user_id: authenticated.claims().user_id().to_string(),
work_id,
published_snapshot_json,
published_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let published = map_published_config_record(published, &request_context)?;
Ok(json_success_body(Some(&request_context), published))
}
pub async fn list_bark_battle_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_bark_battle_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let items = items
.into_iter()
.map(|item| {
let author_display_name =
resolve_bark_battle_author_display_name_for_record(&state, &item);
map_work_summary_record(item, &request_context, author_display_name)
})
.collect::<Result<Vec<_>, _>>()?;
Ok(json_success_body(
Some(&request_context),
BarkBattleWorksResponse { items },
))
}
pub async fn delete_bark_battle_work(
State(state): State<AppState>,
Path(work_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &work_id, "workId")?;
let items = state
.spacetime_client()
.delete_bark_battle_work(BarkBattleWorkDeleteRecordInput {
work_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let items = items
.into_iter()
.map(|item| {
let author_display_name =
resolve_bark_battle_author_display_name_for_record(&state, &item);
map_work_summary_record(item, &request_context, author_display_name)
})
.collect::<Result<Vec<_>, _>>()?;
Ok(json_success_body(
Some(&request_context),
BarkBattleWorksResponse { items },
))
}
pub async fn list_bark_battle_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_bark_battle_gallery()
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let items = items
.into_iter()
.map(|item| {
let author_display_name =
resolve_bark_battle_author_display_name_for_record(&state, &item);
map_work_summary_record(item, &request_context, author_display_name)
})
.collect::<Result<Vec<_>, _>>()?;
Ok(json_success_body(
Some(&request_context),
BarkBattleWorksResponse { items },
))
}
pub async fn get_bark_battle_runtime_config(
State(state): State<AppState>,
Path(work_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &work_id, "workId")?;
let config = state
.spacetime_client()
.get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string()))
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let config = map_runtime_config_record(config, &request_context)?;
Ok(json_success_body(Some(&request_context), config))
}
pub async fn start_bark_battle_run(
State(state): State<AppState>,
Path(work_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let maybe_payload = payload.ok().map(|Json(payload)| payload);
let request = maybe_payload.unwrap_or_else(|| BarkBattleRunStartRequest {
work_id: work_id.clone(),
config_version: None,
source_route: None,
client_runtime_version: None,
});
let work_id = if request.work_id.trim().is_empty() {
work_id
} else {
request.work_id.trim().to_string()
};
ensure_non_empty(&request_context, &work_id, "workId")?;
let owner_user_id = authenticated.claims().user_id().to_string();
let runtime_config = state
.spacetime_client()
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let runtime_config = map_runtime_config_record(runtime_config, &request_context)?;
if !request.work_id.trim().is_empty() && request.work_id.trim() != work_id {
return Err(bark_battle_bad_request(
&request_context,
"workId 与路径参数不一致",
));
}
if let Some(expected_version) = request.config_version {
if expected_version != runtime_config.config_version {
return Err(bark_battle_bad_request(
&request_context,
"configVersion 与已发布配置不一致",
));
}
}
let client_started_at_micros = current_utc_micros();
let run_token = build_prefixed_uuid_id(BARK_BATTLE_RUN_TOKEN_PREFIX);
let run = state
.spacetime_client()
.start_bark_battle_run(BarkBattleRunStartRecordInput {
run_id: build_prefixed_uuid_id(BARK_BATTLE_RUN_ID_PREFIX),
run_token: run_token.clone(),
owner_user_id: owner_user_id.clone(),
work_id: work_id.clone(),
config_version: u64::from(runtime_config.config_version),
ruleset_version: runtime_config.ruleset_version.clone(),
difficulty_preset: difficulty_to_spacetime_string(&runtime_config.difficulty_preset)
.to_string(),
client_started_at_micros,
server_started_at_micros: client_started_at_micros,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let run_snapshot = parse_run_record(run, &request_context)?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
BARK_BATTLE_PLAY_TYPE_ID,
work_id.clone(),
&authenticated,
"/api/runtime/bark-battle/...",
)
.extra(json!({
"runId": run_snapshot.run_id,
"workId": work_id,
"configVersion": runtime_config.config_version,
"rulesetVersion": runtime_config.ruleset_version,
"difficultyPreset": runtime_config.difficulty_preset,
"sourceRoute": request.source_route,
"clientRuntimeVersion": request.client_runtime_version,
})),
)
.await;
let server_started_at = format_timestamp_micros(run_snapshot.server_started_at_micros);
let expires_at = format_timestamp_micros(
run_snapshot
.server_started_at_micros
.saturating_add(BARK_BATTLE_RUN_TTL_SECONDS * 1_000_000),
);
Ok(json_success_body(
Some(&request_context),
BarkBattleRunStartResponse {
run_id: run_snapshot.run_id,
run_token,
work_id: run_snapshot.work_id,
config_version: runtime_config.config_version,
ruleset_version: runtime_config.ruleset_version.clone(),
difficulty_preset: runtime_config.difficulty_preset.clone(),
runtime_config,
server_started_at,
expires_at,
},
))
}
pub async fn get_bark_battle_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_bark_battle_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let run = parse_run_record(run, &request_context)?;
Ok(json_success_body(Some(&request_context), run))
}
pub async fn finish_bark_battle_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
ensure_non_empty(&request_context, &run_id, "runId")?;
ensure_non_empty(&request_context, &payload.work_id, "workId")?;
ensure_non_empty(&request_context, &payload.run_token, "runToken")?;
if payload.run_id != run_id {
return Err(bark_battle_bad_request(
&request_context,
"runId 与路径参数不一致",
));
}
if payload.ruleset_version != BARK_BATTLE_RULESET_VERSION_V1 {
return Err(bark_battle_bad_request(
&request_context,
"rulesetVersion 不支持",
));
}
let client_finished_at_micros = parse_client_time_to_micros(&payload.client_finished_at)
.map_err(|message| bark_battle_bad_request(&request_context, &message))?;
let derived = &payload.derived_metrics;
let opponent_final_energy = derive_server_opponent_final_energy(derived);
let metrics_json = serde_json::to_string(&json!({
"clientStartedAt": payload.client_started_at,
"clientFinishedAt": payload.client_finished_at,
"durationMs": payload.duration_ms,
"derivedMetrics": payload.derived_metrics,
"clientResult": payload.client_result,
"sampleDigest": payload.sample_digest,
"clientRuntimeVersion": payload.client_runtime_version,
}))
.unwrap_or_else(|_| "{}".to_string());
let derived_metrics_json = serde_json::to_string(derived).unwrap_or_else(|_| "{}".to_string());
let run = state
.spacetime_client()
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
run_id,
run_token: payload.run_token,
owner_user_id: authenticated.claims().user_id().to_string(),
work_id: payload.work_id.clone(),
config_version: u64::from(payload.config_version),
ruleset_version: payload.ruleset_version.clone(),
difficulty_preset: difficulty_to_spacetime_string(&payload.difficulty_preset)
.to_string(),
client_finished_at_micros,
server_finished_at_micros: current_utc_micros(),
duration_ms: payload.duration_ms,
trigger_count: u64::from(derived.trigger_count),
max_volume_millis: unit_to_millis(derived.max_volume),
average_volume_millis: unit_to_millis(derived.average_volume),
final_energy_millis: energy_to_millis(derived.final_energy),
opponent_final_energy_millis: energy_to_millis(opponent_final_energy),
max_combo: derived.combo_max,
metrics_json,
derived_metrics_json,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let run = parse_run_record(run, &request_context)?;
Ok(json_success_body(
Some(&request_context),
map_finish_response(run, &payload.derived_metrics),
))
}
fn map_finish_response(
run: BarkBattleRunSnapshotRecord,
fallback_metrics: &BarkBattleDerivedMetrics,
) -> BarkBattleRunFinishResponse {
let score_summary =
parse_score_summary(&run.metrics_json).unwrap_or_else(|| BarkBattleScoreSummary {
duration_ms: 0,
trigger_count: fallback_metrics.trigger_count,
max_volume: fallback_metrics.max_volume,
average_volume: fallback_metrics.average_volume,
final_energy: fallback_metrics.final_energy,
combo_max: fallback_metrics.combo_max,
});
BarkBattleRunFinishResponse {
status: parse_finish_status(&run.validation_status),
run_id: run.run_id,
work_id: run.work_id,
config_version: run.config_version.min(u64::from(u32::MAX)) as u32,
ruleset_version: run.ruleset_version,
difficulty_preset: parse_difficulty_lossy(&run.difficulty_preset),
server_result: parse_server_result_lossy(run.server_result.as_deref()),
score_summary,
leaderboard_score: run.leaderboard_score,
anti_cheat_flags: parse_string_vec(&run.anti_cheat_flags_json),
updated_at: format_timestamp_micros(
run.server_finished_at_micros
.unwrap_or(run.server_started_at_micros),
),
}
}
fn parse_run_record(
value: BarkBattleRunRecord,
request_context: &RequestContext,
) -> Result<BarkBattleRunSnapshotRecord, Response> {
serde_json::from_value(value).map_err(|error| {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle run JSON 解析失败: {error}"),
})),
)
})
}
fn parse_draft_snapshot_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattleDraftConfigSnapshotRecord, Response> {
serde_json::from_value(value)
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "draft config", error))
}
fn parse_runtime_snapshot_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattleRuntimeConfigSnapshotRecord, Response> {
serde_json::from_value(value)
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "runtime config", error))
}
fn map_draft_config_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattleDraftConfig, Response> {
let snapshot = parse_draft_snapshot_record(value, request_context)?;
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_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,
difficulty_preset: editor_config.difficulty_preset,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
})
}
fn map_runtime_config_record(
value: Value,
request_context: &RequestContext,
) -> Result<shared_contracts::bark_battle::BarkBattleRuntimeConfig, Response> {
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
let ruleset = BarkBattleRuleset::v1();
Ok(shared_contracts::bark_battle::BarkBattleRuntimeConfig {
work_id: snapshot.work_id,
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
ruleset_version: snapshot.ruleset_version,
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
duration_ms: ruleset.standard_duration_ms,
energy_min: 0.0,
energy_max: 100.0,
draw_threshold: ruleset.draw_threshold_energy as f32,
min_bark_gap_ms: ruleset.min_bark_gap_ms,
difficulty_preset: editor_config.difficulty_preset,
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,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
})
}
fn map_published_config_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattlePublishedConfig, Response> {
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
Ok(BarkBattlePublishedConfig {
work_id: snapshot.work_id,
draft_id: snapshot.source_draft_id,
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
ruleset_version: snapshot.ruleset_version,
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
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,
difficulty_preset: editor_config.difficulty_preset,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: format_timestamp_micros(snapshot.published_at_micros),
})
}
fn map_work_summary_record(
value: Value,
request_context: &RequestContext,
author_display_name: String,
) -> Result<BarkBattleWorkSummary, Response> {
let status = value
.get("status")
.and_then(Value::as_str)
.unwrap_or("draft")
.to_string();
if status == "published" && value.get("configJson").is_none() {
return map_gallery_work_summary_record(value, request_context, author_display_name);
}
let draft_id = value
.get("draftId")
.and_then(Value::as_str)
.map(ToString::to_string)
.or_else(|| {
value
.get("sourceDraftId")
.and_then(Value::as_str)
.map(ToString::to_string)
});
let owner_user_id = value
.get("ownerUserId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let snapshot = parse_runtime_snapshot_record(value.clone(), request_context).or_else(|_| {
parse_draft_snapshot_record(value.clone(), request_context).map(draft_to_runtime_like)
})?;
let editor_config = resolve_work_summary_editor_config(
&snapshot.config_json,
value.get("publishedSnapshotJson").and_then(Value::as_str),
request_context,
)?;
let is_published = status == "published" || snapshot.published_at_micros > 0;
let generation_status = Some(resolve_generation_status(
editor_config.player_character_image_src.as_deref(),
editor_config.opponent_character_image_src.as_deref(),
editor_config.ui_background_image_src.as_deref(),
));
let publish_ready = has_all_bark_battle_images(&editor_config);
Ok(BarkBattleWorkSummary {
work_id: snapshot.work_id,
draft_id,
owner_user_id,
author_display_name,
title: editor_config.title,
summary: editor_config.description.unwrap_or_default(),
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,
difficulty_preset: editor_config.difficulty_preset,
status: if is_published { "published" } else { "draft" }.to_string(),
generation_status,
publish_ready,
play_count: value.get("playCount").and_then(Value::as_u64).unwrap_or(0),
finish_count: value.get("finishCount").and_then(Value::as_u64),
win_count: None,
draw_count: None,
loss_count: None,
recent_play_count_7d: value.get("recentPlayCount7d").and_then(Value::as_u64),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: is_published.then(|| format_timestamp_micros(snapshot.published_at_micros)),
})
}
fn map_gallery_work_summary_record(
value: Value,
request_context: &RequestContext,
author_display_name: String,
) -> Result<BarkBattleWorkSummary, Response> {
let difficulty = value
.get("difficultyPreset")
.and_then(Value::as_str)
.map(parse_difficulty)
.transpose()
.map_err(|error| bark_battle_error_response(request_context, error))?
.unwrap_or(BarkBattleDifficultyPreset::Normal);
let player_character_image_src = value
.get("playerCharacterImageSrc")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string);
let opponent_character_image_src = value
.get("opponentCharacterImageSrc")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string);
let ui_background_image_src = value
.get("uiBackgroundImageSrc")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string);
let updated_at_micros = value
.get("updatedAtMicros")
.and_then(Value::as_i64)
.unwrap_or_default();
let published_at_micros = value
.get("publishedAtMicros")
.and_then(Value::as_i64)
.unwrap_or(updated_at_micros);
Ok(BarkBattleWorkSummary {
work_id: read_required_json_string(&value, "workId", request_context)?,
draft_id: value
.get("sourceDraftId")
.and_then(Value::as_str)
.map(ToString::to_string),
owner_user_id: read_required_json_string(&value, "ownerUserId", request_context)?,
author_display_name,
title: read_required_json_string(&value, "title", request_context)?,
summary: value
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
theme_description: read_required_json_string(&value, "themeDescription", request_context)?,
player_image_description: read_required_json_string(
&value,
"playerImageDescription",
request_context,
)?,
opponent_image_description: read_required_json_string(
&value,
"opponentImageDescription",
request_context,
)?,
onomatopoeia: read_optional_string_array(&value, "onomatopoeia"),
player_character_image_src,
opponent_character_image_src,
ui_background_image_src,
difficulty_preset: difficulty,
status: "published".to_string(),
generation_status: Some("ready".to_string()),
publish_ready: true,
play_count: value.get("playCount").and_then(Value::as_u64).unwrap_or(0),
finish_count: value.get("finishCount").and_then(Value::as_u64),
win_count: None,
draw_count: None,
loss_count: None,
recent_play_count_7d: value.get("recentPlayCount7d").and_then(Value::as_u64),
updated_at: format_timestamp_micros(updated_at_micros),
published_at: Some(format_timestamp_micros(published_at_micros)),
})
}
fn resolve_work_summary_editor_config(
config_json: &str,
published_snapshot_json: Option<&str>,
request_context: &RequestContext,
) -> Result<BarkBattleConfigEditorPayload, Response> {
let snapshot = published_snapshot_json
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(config_json);
parse_editor_config_record(snapshot, request_context)
}
fn draft_to_runtime_like(
draft: BarkBattleDraftConfigSnapshotRecord,
) -> BarkBattleRuntimeConfigSnapshotRecord {
BarkBattleRuntimeConfigSnapshotRecord {
work_id: draft.work_id,
source_draft_id: Some(draft.draft_id),
config_version: draft.config_version,
ruleset_version: draft.ruleset_version,
config_json: draft.config_json,
published_at_micros: 0,
updated_at_micros: draft.updated_at_micros,
}
}
fn resolve_generation_status(
player_src: Option<&str>,
opponent_src: Option<&str>,
background_src: Option<&str>,
) -> String {
if player_src.is_some() && opponent_src.is_some() && background_src.is_some() {
"ready".to_string()
} else {
"pending_assets".to_string()
}
}
fn has_all_bark_battle_images(config: &BarkBattleConfigEditorPayload) -> bool {
config
.player_character_image_src
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& config
.opponent_character_image_src
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& config
.ui_background_image_src
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}
fn resolve_bark_battle_author_display_name_for_record(state: &AppState, value: &Value) -> String {
let owner_user_id = value
.get("ownerUserId")
.and_then(Value::as_str)
.unwrap_or_default();
resolve_bark_battle_author_display_name(state, owner_user_id)
}
fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String {
resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
}
fn normalize_author_display_name(display_name: Option<String>) -> String {
display_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
fn read_required_json_string(
value: &Value,
field_name: &str,
request_context: &RequestContext,
) -> Result<String, Response> {
value
.get(field_name)
.and_then(Value::as_str)
.map(ToString::to_string)
.ok_or_else(|| {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle work summary 缺少字段: {field_name}"),
})),
)
})
}
fn read_optional_string_array(value: &Value, field_name: &str) -> Option<Vec<String>> {
let words: Vec<String> = value
.get(field_name)?
.as_array()?
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|word| !word.is_empty())
.map(ToString::to_string)
.collect();
(!words.is_empty()).then_some(words)
}
fn parse_editor_config_record(
config_json: &str,
request_context: &RequestContext,
) -> Result<BarkBattleConfigEditorPayload, Response> {
serde_json::from_str(config_json).map_err(|error| {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle configJson 解析失败: {error}"),
})),
)
})
}
fn serialize_bark_battle_editor_config(
request_context: &RequestContext,
editor_config: &BarkBattleConfigEditorPayload,
) -> Result<String, Response> {
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}"),
})),
)
})
}
fn build_bark_battle_image_prompt(
slot: &BarkBattleAssetSlot,
config: &BarkBattleConfigEditorPayload,
) -> String {
let title = config.title.trim();
let description = config.description.as_deref().unwrap_or_default().trim();
let theme_description = config.theme_description.trim();
let player_description = config.player_image_description.trim();
let opponent_description = config.opponent_image_description.trim();
let slot_prompt = match slot {
BarkBattleAssetSlot::PlayerCharacter => format!(
"玩家形象描述:{player_description}。输出单个完整角色/形象正面主体完整PNG 透明背景,适合叠加在竖屏声控对战游戏 HUD 中。"
),
BarkBattleAssetSlot::OpponentCharacter => format!(
"对手形象描述:{opponent_description}。输出单个完整角色/形象正面主体完整PNG 透明背景,适合叠加在竖屏声控对战游戏 HUD 中。"
),
BarkBattleAssetSlot::UiBackground => format!(
"竞技背景描述:{theme_description}。输出竖屏移动端声浪竞技场背景,留出左右两侧角色站位和中部能量对抗空间,不包含具体角色。"
),
};
let mut parts = vec![
format!("本作品《{title}》专用素材。"),
format!("整体主题/场景:{theme_description}"),
];
if !description.is_empty() {
parts.push(format!("作品简介:{description}"));
}
if !matches!(slot, BarkBattleAssetSlot::UiBackground) {
parts.push(format!(
"玩家与对手的关系参考:玩家是「{player_description}」,对手是「{opponent_description}」。"
));
}
parts.push(slot_prompt);
parts.push(match slot {
BarkBattleAssetSlot::UiBackground => {
"画面要求9:16 竖屏游戏背景,明亮、有纵深、无文字、无 Logo、无按钮、无 UI 字、无水印、无角色、无狗狗。"
.to_string()
}
_ => {
"画面要求1:1 角色图,透明背景,边缘清晰,无文字、无 Logo、无按钮、无水印不要把竞技场背景画成主体。最终画面必须是 PNG 透明背景。"
.to_string()
}
});
parts
.into_iter()
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn bark_battle_image_size(slot: &BarkBattleAssetSlot) -> &'static str {
match slot {
BarkBattleAssetSlot::UiBackground => BARK_BATTLE_BACKGROUND_IMAGE_SIZE,
BarkBattleAssetSlot::PlayerCharacter | BarkBattleAssetSlot::OpponentCharacter => {
BARK_BATTLE_CHARACTER_IMAGE_SIZE
}
}
}
fn bark_battle_slot_asset_kind(slot: &BarkBattleAssetSlot) -> &'static str {
match slot {
BarkBattleAssetSlot::PlayerCharacter => "bark_battle_player_character_image",
BarkBattleAssetSlot::OpponentCharacter => "bark_battle_opponent_character_image",
BarkBattleAssetSlot::UiBackground => "bark_battle_ui_background_image",
}
}
fn bark_battle_slot_name(slot: &BarkBattleAssetSlot) -> &'static str {
match slot {
BarkBattleAssetSlot::PlayerCharacter => "player-character",
BarkBattleAssetSlot::OpponentCharacter => "opponent-character",
BarkBattleAssetSlot::UiBackground => "ui-background",
}
}
fn bark_battle_slot_storage_segment(slot: &BarkBattleAssetSlot) -> &'static str {
match slot {
BarkBattleAssetSlot::PlayerCharacter => "player",
BarkBattleAssetSlot::OpponentCharacter => "opponent",
BarkBattleAssetSlot::UiBackground => "background",
}
}
fn bark_battle_sanitize_path_segment(value: &str, fallback: &str) -> String {
let sanitized = value
.trim()
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
ch if ch.is_control() => '-',
ch => ch,
})
.collect::<String>()
.trim_matches('-')
.chars()
.take(72)
.collect::<String>();
if sanitized.trim().is_empty() {
fallback.to_string()
} else {
sanitized
}
}
async fn generate_and_persist_bark_battle_image_asset(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
slot: &BarkBattleAssetSlot,
draft_id: Option<&str>,
asset_id: &str,
prompt: &str,
size: &str,
) -> Result<BarkBattleGeneratedImageAsset, AppError> {
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
&request_context,
Some(owner_user_id.to_string()),
Some(draft_id.unwrap_or(asset_id).to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt,
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
size,
1,
&[],
"汪汪声浪素材生成失败",
)
.await?;
let task_id = generated.task_id.clone();
let actual_prompt = generated.actual_prompt.clone();
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "汪汪声浪素材生成成功但未返回图片。",
}))
})?;
let image_src = persist_bark_battle_generated_image(
state,
owner_user_id,
slot,
draft_id,
asset_id,
task_id.as_str(),
image,
)
.await?;
Ok(BarkBattleGeneratedImageAsset {
image_src,
asset_id: asset_id.to_string(),
source_type: Some("generated".to_string()),
model: GPT_IMAGE_2_MODEL.to_string(),
size: size.to_string(),
task_id,
prompt: prompt.to_string(),
actual_prompt,
})
}
async fn persist_bark_battle_generated_image(
state: &AppState,
owner_user_id: &str,
slot: &BarkBattleAssetSlot,
draft_id: Option<&str>,
asset_id: &str,
task_id: &str,
image: crate::openai_image_generation::DownloadedOpenAiImage,
) -> Result<String, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let entity_id = draft_id
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(asset_id)
.to_string();
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::BarkBattleAssets,
path_segments: vec![
bark_battle_sanitize_path_segment(entity_id.as_str(), "draft"),
bark_battle_slot_storage_segment(slot).to_string(),
bark_battle_sanitize_path_segment(asset_id, "asset"),
],
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
bytes: image.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(bark_battle_slot_asset_kind(slot).to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some("bark_battle_draft".to_string()),
entity_id: Some(entity_id.clone()),
slot: Some(bark_battle_slot_name(slot).to_string()),
provider: Some("vector-engine".to_string()),
task_id: Some(task_id.to_string()),
},
extra_metadata: Default::default(),
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备汪汪声浪图片资产上传请求失败:{error:?}"),
}))
})?;
let persisted_mime_type = prepared.format.mime_type.clone();
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
bark_battle_slot_asset_kind(slot).to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
draft_id.map(ToString::to_string),
Some(entity_id.clone()),
now_micros,
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
})?,
)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
})?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id,
"bark_battle_draft".to_string(),
entity_id,
bark_battle_slot_name(slot).to_string(),
bark_battle_slot_asset_kind(slot).to_string(),
Some(owner_user_id.to_string()),
draft_id.map(ToString::to_string),
now_micros,
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
})?,
)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
})?;
Ok(put_result.legacy_public_path)
}
fn bark_battle_snapshot_parse_error(
request_context: &RequestContext,
label: &str,
error: serde_json::Error,
) -> Response {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle {label} JSON 解析失败: {error}"),
})),
)
}
fn bark_battle_json<T>(
payload: Result<Json<T>, JsonRejection>,
request_context: &RequestContext,
) -> Result<Json<T>, Response> {
payload.map_err(|error| {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})
}
fn ensure_non_empty(
request_context: &RequestContext,
value: &str,
field_name: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(bark_battle_bad_request(
request_context,
&format!("{field_name} is required"),
));
}
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,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": message,
})),
)
}
fn map_bark_battle_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("不能为空")
|| message.contains("不匹配")
|| message.contains("不支持")
|| message.contains("已结束")
|| message.contains("已存在") =>
{
StatusCode::BAD_REQUEST
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn bark_battle_error_response(request_context: &RequestContext, error: AppError) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_static(BARK_BATTLE_RUNTIME_PROVIDER),
);
response
}
fn parse_client_time_to_micros(value: &str) -> Result<i64, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("client timestamp is required".to_string());
}
if let Ok(micros) = trimmed.parse::<i64>() {
return Ok(micros);
}
parse_rfc3339(trimmed).map(offset_datetime_to_unix_micros)
}
fn current_utc_micros() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
}
fn unit_to_millis(value: f32) -> u32 {
(value.clamp(0.0, 1.0) * 1_000.0).round() as u32
}
fn energy_to_millis(value: f32) -> u32 {
(value.clamp(0.0, 100.0) * 1_000.0).round() as u32
}
fn derive_server_opponent_final_energy(metrics: &BarkBattleDerivedMetrics) -> f32 {
let ruleset = BarkBattleRuleset::v1();
let pressure = (metrics.average_volume * 24.0)
+ (metrics.max_volume * 16.0)
+ (metrics.trigger_count as f32 * 0.35)
+ (metrics.combo_max as f32 * 0.2);
(ruleset.max_final_energy - pressure).clamp(ruleset.min_final_energy, ruleset.max_final_energy)
}
fn difficulty_to_spacetime_string(value: &BarkBattleDifficultyPreset) -> &'static str {
match value {
BarkBattleDifficultyPreset::Easy => "easy",
BarkBattleDifficultyPreset::Normal => "normal",
BarkBattleDifficultyPreset::Hard => "hard",
}
}
fn parse_difficulty(value: &str) -> Result<BarkBattleDifficultyPreset, AppError> {
match value {
"easy" => Ok(BarkBattleDifficultyPreset::Easy),
"normal" => Ok(BarkBattleDifficultyPreset::Normal),
"hard" => Ok(BarkBattleDifficultyPreset::Hard),
_ => Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle difficultyPreset 不支持: {value}"),
})),
),
}
}
fn parse_difficulty_lossy(value: &str) -> BarkBattleDifficultyPreset {
parse_difficulty(value).unwrap_or(BarkBattleDifficultyPreset::Normal)
}
fn parse_finish_status(value: &str) -> BarkBattleFinishStatus {
match value {
"accepted" => BarkBattleFinishStatus::Accepted,
"accepted_with_flags" => BarkBattleFinishStatus::AcceptedWithFlags,
"rejected" => BarkBattleFinishStatus::Rejected,
_ => BarkBattleFinishStatus::Rejected,
}
}
fn parse_server_result_lossy(value: Option<&str>) -> BarkBattleServerResult {
match value {
Some("player_win") => BarkBattleServerResult::PlayerWin,
Some("opponent_win") => BarkBattleServerResult::OpponentWin,
Some("draw") => BarkBattleServerResult::Draw,
_ => BarkBattleServerResult::Draw,
}
}
fn parse_score_summary(metrics_json: &str) -> Option<BarkBattleScoreSummary> {
let value: Value = serde_json::from_str(metrics_json).ok()?;
let derived = value.get("derivedMetrics")?;
Some(BarkBattleScoreSummary {
duration_ms: value.get("durationMs")?.as_u64()?,
trigger_count: derived
.get("triggerCount")?
.as_u64()?
.min(u64::from(u32::MAX)) as u32,
max_volume: derived.get("maxVolume")?.as_f64()? as f32,
average_volume: derived.get("averageVolume")?.as_f64()? as f32,
final_energy: derived.get("finalEnergy")?.as_f64()? as f32,
combo_max: derived.get("comboMax")?.as_u64()?.min(u64::from(u32::MAX)) as u32,
})
}
fn parse_string_vec(value: &str) -> Vec<String> {
serde_json::from_str(value).unwrap_or_default()
}
#[allow(dead_code)]
fn format_rfc3339_or_timestamp_micros(micros: i64) -> String {
let seconds = micros.div_euclid(1_000_000);
let subsec_micros = micros.rem_euclid(1_000_000);
let Ok(value) = OffsetDateTime::from_unix_timestamp(seconds)
.map(|value| value + TimeDuration::microseconds(subsec_micros))
else {
return format_timestamp_micros(micros);
};
format_rfc3339(value).unwrap_or_else(|_| format_timestamp_micros(micros))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn unit_and_energy_are_clamped_to_spacetime_millis() {
assert_eq!(unit_to_millis(0.625), 625);
assert_eq!(unit_to_millis(3.0), 1000);
assert_eq!(energy_to_millis(88.456), 88_456);
assert_eq!(energy_to_millis(120.0), 100_000);
}
#[test]
fn parses_rfc3339_and_numeric_client_timestamps() {
assert_eq!(
parse_client_time_to_micros("1713686401234567").unwrap(),
1_713_686_401_234_567
);
assert_eq!(
parse_client_time_to_micros("2024-04-21T04:00:01.234567Z").unwrap(),
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": "",
"themeDescription": "阳光草坪声浪擂台",
"playerImageDescription": "主角柴犬",
"opponentImageDescription": "对手哈士奇",
"onomatopoeia": ["轰汪!", "炸场!", "冲啊!"],
"difficultyPreset": "normal"
})
.to_string();
let row = json!({
"draftId": "bark-battle-draft-1",
"workId": "BB-12345678",
"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("BB-12345678"));
assert_eq!(draft.config_version, Some(2));
assert_eq!(
draft.ruleset_version.as_deref(),
Some("bark-battle-ruleset-v1")
);
}
#[test]
fn bark_battle_image_prompts_are_slot_specific() {
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(),
"冲啊!".to_string(),
]),
player_character_image_src: None,
opponent_character_image_src: None,
ui_background_image_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal,
};
let player_prompt =
build_bark_battle_image_prompt(&BarkBattleAssetSlot::PlayerCharacter, &config);
assert!(player_prompt.contains("玩家形象描述:星际猫骑士"));
assert!(player_prompt.contains("正面"));
assert!(player_prompt.contains("PNG 透明背景"));
assert!(player_prompt.contains("透明背景"));
assert!(player_prompt.contains("1:1 角色图"));
assert!(!player_prompt.contains(""));
assert!(!player_prompt.contains("狗狗"));
assert!(!player_prompt.contains("小狗"));
assert!(!player_prompt.contains(""));
assert!(!player_prompt.contains("汪汪"));
assert!(!player_prompt.contains("横版 16:9 2D RPG 场景背景"));
assert!(!player_prompt.contains("远景剪影"));
let opponent_prompt =
build_bark_battle_image_prompt(&BarkBattleAssetSlot::OpponentCharacter, &config);
assert!(opponent_prompt.contains("对手形象描述:机器人拳手"));
assert!(opponent_prompt.contains("正面"));
assert!(opponent_prompt.contains("PNG 透明背景"));
assert!(opponent_prompt.contains("透明背景"));
assert!(opponent_prompt.contains("1:1 角色图"));
assert!(!opponent_prompt.contains(""));
assert!(!opponent_prompt.contains("狗狗"));
assert!(!opponent_prompt.contains("小狗"));
assert!(!opponent_prompt.contains(""));
assert!(!opponent_prompt.contains("汪汪"));
assert!(!opponent_prompt.contains("横版 16:9 2D RPG 场景背景"));
assert!(!opponent_prompt.contains("远景剪影"));
let background_prompt =
build_bark_battle_image_prompt(&BarkBattleAssetSlot::UiBackground, &config);
assert!(background_prompt.contains("竞技背景描述:霓虹城市公园里的声浪擂台"));
assert!(background_prompt.contains("9:16 竖屏游戏背景"));
assert!(background_prompt.contains("无角色、无狗狗"));
assert!(!background_prompt.contains("玩家与对手的关系参考"));
}
#[test]
fn bark_battle_work_summary_mapping_uses_resolved_author_display_name() {
let request_context = RequestContext::new(
"test-request".to_string(),
"GET /api/creation/bark-battle/works".to_string(),
Duration::ZERO,
false,
);
let config_json = json!({
"title": "声浪测试局",
"description": "映射测试",
"themeDescription": "星环竞技场",
"playerImageDescription": "星际猫骑士",
"opponentImageDescription": "机器人拳手",
"onomatopoeia": ["轰!", "炸场!", "冲啊!"],
"difficultyPreset": "normal"
})
.to_string();
let work_row = json!({
"draftId": "bark-battle-draft-2",
"workId": "BB-22222222",
"ownerUserId": "user-2",
"configVersion": 1,
"rulesetVersion": "bark-battle-ruleset-v1",
"configJson": config_json,
"updatedAtMicros": 1_713_686_401_234_567i64,
"status": "draft"
});
let work = map_work_summary_record(work_row, &request_context, " 星环作者 ".to_string())
.expect("work summary should use provided author display name");
assert_eq!(work.author_display_name, " 星环作者 ");
}
#[test]
fn bark_battle_gallery_mapping_uses_resolved_author_display_name() {
let request_context = RequestContext::new(
"test-request".to_string(),
"GET /api/creation/bark-battle/gallery".to_string(),
Duration::ZERO,
false,
);
let gallery_row = json!({
"status": "published",
"workId": "BB-33333333",
"ownerUserId": "user-3",
"sourceDraftId": "bark-battle-draft-3",
"title": "声浪公开赛",
"description": "画廊映射测试",
"themeDescription": "霓虹竞技场",
"playerImageDescription": "星际猫骑士",
"opponentImageDescription": "机器人拳手",
"onomatopoeia": ["轰!", "炸场!", "冲啊!"],
"playerCharacterImageSrc": "/assets/player.png",
"opponentCharacterImageSrc": "/assets/opponent.png",
"uiBackgroundImageSrc": "/assets/background.png",
"difficultyPreset": "normal",
"playCount": 8,
"finishCount": 5,
"recentPlayCount7d": 3,
"updatedAtMicros": 1_713_686_401_234_567i64,
"publishedAtMicros": 1_713_686_401_234_000i64
});
let work = map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string())
.expect("gallery summary should use provided author display name");
assert_eq!(work.author_display_name, "画廊作者");
assert_eq!(
work.onomatopoeia,
Some(vec![
"轰!".to_string(),
"炸场!".to_string(),
"冲啊!".to_string(),
])
);
}
#[test]
fn bark_battle_published_summary_uses_published_snapshot_assets() {
let request_context = RequestContext::new(
"test-request".to_string(),
"GET /api/creation/bark-battle/works".to_string(),
Duration::ZERO,
false,
);
let draft_config_json = json!({
"title": "声浪测试局",
"description": "发布前草稿",
"themeDescription": "草地竞技场",
"playerImageDescription": "柯基选手",
"opponentImageDescription": "哈士奇对手",
"difficultyPreset": "normal"
})
.to_string();
let published_snapshot_json = json!({
"title": "声浪测试局",
"description": "发布后快照",
"themeDescription": "草地竞技场",
"playerImageDescription": "柯基选手",
"opponentImageDescription": "哈士奇对手",
"playerCharacterImageSrc": "/assets/player.png",
"opponentCharacterImageSrc": "/assets/opponent.png",
"uiBackgroundImageSrc": "/assets/background.png",
"difficultyPreset": "normal"
})
.to_string();
let work_row = json!({
"sourceDraftId": "bark-battle-draft-published",
"workId": "BB-44444444",
"ownerUserId": "user-4",
"configVersion": 1,
"rulesetVersion": "bark-battle-ruleset-v1",
"configJson": draft_config_json,
"publishedSnapshotJson": published_snapshot_json,
"publishedAtMicros": 1_713_686_401_234_000i64,
"updatedAtMicros": 1_713_686_401_234_567i64
});
let work = map_work_summary_record(work_row, &request_context, "发布作者".to_string())
.expect("published summary should use published snapshot assets");
assert_eq!(work.status, "published");
assert_eq!(
work.player_character_image_src.as_deref(),
Some("/assets/player.png")
);
assert_eq!(
work.opponent_character_image_src.as_deref(),
Some("/assets/opponent.png")
);
assert_eq!(
work.ui_background_image_src.as_deref(),
Some("/assets/background.png")
);
assert_eq!(work.summary, "发布后快照");
assert!(work.publish_ready);
}
#[test]
fn normalize_author_display_name_trims_and_falls_back_to_player() {
assert_eq!(
normalize_author_display_name(Some(" 小陶 ".to_string())),
"小陶"
);
assert_eq!(
normalize_author_display_name(Some(" ".to_string())),
"玩家"
);
assert_eq!(normalize_author_display_name(None), "玩家");
}
}