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

1884 lines
72 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,
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_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,
&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 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 {
let display_name = if owner_user_id.trim().is_empty() {
None
} else {
state
.auth_user_service()
.get_user_by_id(owner_user_id)
.ok()
.flatten()
.map(|user| user.display_name)
};
normalize_author_display_name(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,
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)?;
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), "玩家");
}
}