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, #[serde(default)] metrics_json: String, #[serde(default)] server_result: Option, #[serde(default)] validation_status: String, #[serde(default)] anti_cheat_flags_json: String, #[serde(default)] leaderboard_score: Option, } #[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, 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, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(draft_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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::, _>>()?; Ok(json_success_body( Some(&request_context), BarkBattleWorksResponse { items }, )) } pub async fn list_bark_battle_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, 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::, _>>()?; Ok(json_success_body( Some(&request_context), BarkBattleWorksResponse { items }, )) } pub async fn get_bark_battle_runtime_config( State(state): State, Path(work_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(work_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> { let words: Vec = 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 { 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 { 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::>() .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::() .trim_matches('-') .chars() .take(72) .collect::(); 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 { 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 { 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( payload: Result, JsonRejection>, request_context: &RequestContext, ) -> Result, 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, 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 { let trimmed = value.trim(); if trimmed.is_empty() { return Err("client timestamp is required".to_string()); } if let Ok(micros) = trimmed.parse::() { 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 { 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 { 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 { 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), "玩家"); } }