1884 lines
72 KiB
Rust
1884 lines
72 KiB
Rust
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), "玩家");
|
||
}
|
||
}
|