use super::*; pub(super) async fn submit_and_finalize_match3d_message( state: &AppState, request_context: &RequestContext, owner_user_id: &str, session_id: String, payload: SendMatch3DAgentMessageRequest, ) -> Result { ensure_non_empty( request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; ensure_non_empty( request_context, MATCH3D_AGENT_PROVIDER, &payload.client_message_id, "clientMessageId", )?; ensure_non_empty( request_context, MATCH3D_AGENT_PROVIDER, &payload.text, "text", )?; let submitted = state .spacetime_client() .submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.to_string(), user_message_id: payload.client_message_id.clone(), user_message_text: payload.text.clone(), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; let next_turn = submitted.current_turn.saturating_add(1); let next_config = build_config_from_message(&submitted, &payload); let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn); let progress_percent = resolve_progress_percent_for_turn(next_turn); let stage = if progress_percent >= 100 { "ReadyToCompile" } else { "Collecting" } .to_string(); state .spacetime_client() .finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput { session_id, owner_user_id: owner_user_id.to_string(), assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)), assistant_reply_text: Some(assistant_reply), config_json: serialize_match3d_config(&next_config), progress_percent, stage, updated_at_micros: current_utc_micros(), error_message: None, }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) }) } pub(super) async fn load_match3d_agent_session_response_with_persisted_assets( state: &AppState, owner_user_id: &str, session: Match3DAgentSessionRecord, ) -> Match3DAgentSessionSnapshotResponse { let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else { return map_match3d_agent_session_response(session); }; let assets = get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await; map_match3d_agent_session_response_with_assets(session, &assets) } fn resolve_match3d_session_existing_profile_id( session: &Match3DAgentSessionRecord, ) -> Option { session .draft .as_ref() .map(|draft| draft.profile_id.trim()) .filter(|profile_id| !profile_id.is_empty()) .or_else(|| { session .published_profile_id .as_deref() .map(str::trim) .filter(|profile_id| !profile_id.is_empty()) }) .map(str::to_string) } pub(super) async fn compile_match3d_draft_for_session( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, game_name: Option, summary: Option, tags: Option>, cover_image_src: Option, generate_click_sound: Option, ) -> Result<(Match3DAgentSessionRecord, Vec), Response> { let owner_user_id = authenticated.claims().user_id().to_string(); let initial_session = state .spacetime_client() .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; let mut config = resolve_config_or_default(initial_session.config.as_ref()); if let Some(generate_click_sound) = generate_click_sound { config.generate_click_sound = generate_click_sound; } // 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session // 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。 let has_complete_form_config = !config.theme_text.trim().is_empty() && config.clear_count > 0 && (1..=10).contains(&config.difficulty); if !has_complete_form_config && (initial_session.current_turn < 3 || initial_session.progress_percent < 100) { return Err(match3d_bad_request( request_context, MATCH3D_AGENT_PROVIDER, "match3d 创作配置尚未确认完成", )); } let requested_game_name = normalize_optional_match3d_text(game_name); let requested_summary = normalize_optional_match3d_text(summary); let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty()); let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src); let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); let profile_id = resolve_match3d_draft_profile_id(&initial_session); let initial_game_name = requested_game_name .clone() .unwrap_or_else(|| fallback_work_metadata.game_name.clone()); let initial_tags = requested_tags .clone() .unwrap_or_else(|| fallback_work_metadata.tags.clone()); let billing_asset_id = format!( "{}:{}:{}", session_id, profile_id, request_context.request_id() ); let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, "match3d", MATCH3D_DRAFT_GENERATION_POINTS_COST, ) .await; let compile_session_id = session_id.clone(); let compile_owner_user_id = owner_user_id.clone(); let compile_profile_id = profile_id.clone(); let compile_initial_game_name = initial_game_name.clone(); let compile_requested_summary = requested_summary.clone(); let compile_initial_tags = initial_tags.clone(); let compile_requested_cover_image_src = requested_cover_image_src.clone(); let result = execute_billable_match3d_draft_generation( state, request_context, owner_user_id.as_str(), billing_asset_id.as_str(), points_cost, async { let mut session = upsert_match3d_draft_snapshot( state, request_context, authenticated, session_id.clone(), owner_user_id.clone(), profile_id.clone(), Some(initial_game_name), requested_summary.clone().or_else(|| Some(String::new())), Some(serde_json::to_string(&initial_tags).unwrap_or_default()), requested_cover_image_src.clone(), None, None, ) .await?; if session.draft.is_none() { return Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"), )); } let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await; let resolved_game_name = requested_game_name .unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone()); let resolved_summary = requested_summary .clone() .unwrap_or_else(|| generated_work_metadata.metadata.summary.clone()); let resolved_tags = match requested_tags { Some(tags) => tags, None => { generate_match3d_work_tags_for_plan( state, resolved_game_name.as_str(), config.theme_text.as_str(), resolved_summary.as_str(), &generated_work_metadata.metadata.tags, ) .await } }; generated_work_metadata.metadata.tags = resolved_tags.clone(); session = upsert_match3d_draft_snapshot( state, request_context, authenticated, session_id, owner_user_id.clone(), profile_id.clone(), Some(resolved_game_name), Some(resolved_summary), Some(serde_json::to_string(&resolved_tags).unwrap_or_default()), requested_cover_image_src.clone(), None, None, ) .await?; let mut existing_assets = get_match3d_existing_generated_item_assets( state, owner_user_id.as_str(), profile_id.as_str(), ) .await; let generated_background_asset = resolve_or_generate_match3d_level_asset_bundle( state, request_context, owner_user_id.as_str(), session.session_id.as_str(), profile_id.as_str(), &config, generated_work_metadata.background_prompt.as_str(), &existing_assets, ) .await?; attach_match3d_background_asset_to_assets( &mut existing_assets, generated_background_asset.clone(), ); let generated_item_assets = generate_match3d_item_assets( state, request_context, authenticated, owner_user_id.as_str(), session.session_id.as_str(), profile_id.as_str(), &config, generated_work_metadata.items, existing_assets, Some(generated_background_asset.clone()), ) .await?; let mut generated_item_assets = generated_item_assets; attach_match3d_background_asset_to_assets( &mut generated_item_assets, generated_background_asset, ); persist_match3d_generated_item_assets_snapshot( state, request_context, authenticated, session.session_id.as_str(), owner_user_id.as_str(), profile_id.as_str(), &generated_item_assets, ) .await?; let existing_cover_image_src = get_match3d_existing_cover_image_src( state, owner_user_id.as_str(), profile_id.as_str(), ) .await; let default_cover_image_src = requested_cover_image_src .clone() .or(existing_cover_image_src) .or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets)); let next_session = upsert_match3d_draft_snapshot( state, request_context, authenticated, session.session_id.clone(), owner_user_id.clone(), profile_id, None, None, None, default_cover_image_src, None, serialize_match3d_generated_item_assets(&generated_item_assets), ) .await?; Ok((next_session, generated_item_assets)) }, ) .await; match result { Ok((session, generated_item_assets)) => { send_generation_result_subscribe_message_after_completion( state, GenerationResultSubscribeMessage { owner_user_id: compile_owner_user_id.clone(), task_name: Some("抓大鹅".to_string()), work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()), status: GenerationResultSubscribeMessageStatus::Succeeded, consumed_points: points_cost, completed_at_micros: current_utc_micros(), page: Some("/pages/web-view/index".to_string()), }, ) .await; Ok((session, generated_item_assets)) } Err(response) if response.status().is_server_error() => { let failure_message = match3d_response_failure_message(&response); persist_failed_match3d_draft_generation( state, request_context, authenticated, compile_session_id, compile_owner_user_id.clone(), compile_profile_id, compile_initial_game_name.clone(), compile_requested_summary, compile_initial_tags, compile_requested_cover_image_src, failure_message, ) .await; send_generation_result_subscribe_message_after_completion( state, GenerationResultSubscribeMessage { owner_user_id: compile_owner_user_id, task_name: Some("抓大鹅".to_string()), work_name: Some(compile_initial_game_name), status: GenerationResultSubscribeMessageStatus::Failed, consumed_points: 0, completed_at_micros: current_utc_micros(), page: Some("/pages/web-view/index".to_string()), }, ) .await; Err(response) } Err(response) => Err(response), } } #[allow(clippy::too_many_arguments)] async fn persist_failed_match3d_draft_generation( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, owner_user_id: String, profile_id: String, game_name: String, summary: Option, tags: Vec, cover_image_src: Option, failure_message: String, ) { let failure_assets_json = serialize_match3d_failed_generation_assets(failure_message.as_str()); if let Err(persist_error) = upsert_match3d_draft_snapshot( state, request_context, authenticated, session_id, owner_user_id, profile_id, Some(game_name), summary.or_else(|| Some(String::new())), Some(serde_json::to_string(&tags).unwrap_or_default()), cover_image_src, None, failure_assets_json, ) .await { tracing::error!( provider = MATCH3D_AGENT_PROVIDER, status = ?persist_error.status(), "抓大鹅草稿生成失败后的状态回写失败" ); } } fn serialize_match3d_failed_generation_assets(message: &str) -> Option { let background_asset = Match3DGeneratedBackgroundAsset { prompt: String::new(), status: "failed".to_string(), error: Some(message.trim().to_string()), ..Default::default() }; let assets = vec![Match3DGeneratedItemAssetJson { item_id: "match3d-generation-failure".to_string(), item_name: "生成失败".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: Some(background_asset), status: "failed".to_string(), error: Some(message.trim().to_string()), }]; serde_json::to_string(&assets).ok() } fn match3d_response_failure_message(response: &Response) -> String { response .extensions() .get::() .cloned() .unwrap_or_else(|| format!("抓大鹅草稿生成失败,HTTP {}", response.status())) } /// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按后台入口配置的泥点成本幂等预扣。 async fn execute_billable_match3d_draft_generation( state: &AppState, request_context: &RequestContext, owner_user_id: &str, billing_asset_id: &str, points_cost: u64, operation: Fut, ) -> Result where Fut: Future>, { let points_consumed = consume_match3d_draft_generation_points( state, request_context, owner_user_id, billing_asset_id, points_cost, ) .await?; match operation.await { Ok(value) => Ok(value), Err(response) => { if points_consumed { refund_match3d_draft_generation_points( state, owner_user_id, billing_asset_id, points_cost, ) .await; } Err(response) } } } async fn consume_match3d_draft_generation_points( state: &AppState, request_context: &RequestContext, owner_user_id: &str, billing_asset_id: &str, points_cost: u64, ) -> Result { let ledger_id = format!( "asset_operation_consume:{}:match3d_draft_generation:{}", owner_user_id, billing_asset_id ); match state .spacetime_client() .consume_profile_wallet_points( owner_user_id.to_string(), points_cost, ledger_id, current_utc_micros(), ) .await { Ok(_) => Ok(true), Err(error) => Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_asset_operation_wallet_error(error), )), } } async fn refund_match3d_draft_generation_points( state: &AppState, owner_user_id: &str, billing_asset_id: &str, points_cost: u64, ) { let ledger_id = format!( "asset_operation_refund:{}:match3d_draft_generation:{}", owner_user_id, billing_asset_id ); if let Err(error) = state .spacetime_client() .refund_profile_wallet_points( owner_user_id.to_string(), points_cost, ledger_id, current_utc_micros(), ) .await { tracing::error!( owner_user_id, billing_asset_id, error = %error, "抓大鹅草稿生成失败后的泥点退款失败" ); } } fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String { session .draft .as_ref() .map(|draft| draft.profile_id.trim()) .filter(|profile_id| !profile_id.is_empty()) .or_else(|| { session .published_profile_id .as_deref() .map(str::trim) .filter(|profile_id| !profile_id.is_empty()) }) .map(str::to_string) .unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX)) } #[allow(clippy::too_many_arguments)] pub(super) async fn upsert_match3d_draft_snapshot( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, owner_user_id: String, profile_id: String, game_name: Option, summary_text: Option, tags_json: Option, cover_image_src: Option, cover_asset_id: Option, generated_item_assets_json: Option, ) -> Result { state .spacetime_client() .compile_match3d_draft(Match3DCompileDraftRecordInput { session_id, owner_user_id, profile_id, author_display_name: resolve_author_display_name(state, authenticated), game_name, summary_text, tags_json, cover_image_src, cover_asset_id, compiled_at_micros: current_utc_micros(), generated_item_assets_json, }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) }) } pub(super) fn build_config_from_create_request( payload: &CreateMatch3DAgentSessionRequest, ) -> Match3DConfigJson { Match3DConfigJson { theme_text: payload .theme_text .as_deref() .or(payload.seed_text.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(MATCH3D_DEFAULT_THEME) .to_string(), reference_image_src: payload.reference_image_src.clone(), clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT), difficulty: payload .difficulty .unwrap_or(MATCH3D_DEFAULT_DIFFICULTY) .clamp(1, 10), asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()), asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()), asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()), generate_click_sound: payload.generate_click_sound.unwrap_or(false), } } fn build_config_from_message( session: &Match3DAgentSessionRecord, payload: &SendMatch3DAgentMessageRequest, ) -> Match3DConfigJson { let current = resolve_config_or_default(session.config.as_ref()); let text = payload.text.trim(); let reference_image_src = payload .reference_image_src .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or(current.reference_image_src); let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置"); let mut theme_text = current.theme_text; let mut clear_count = current.clear_count.max(1); let mut difficulty = current.difficulty.clamp(1, 10); let asset_style_id = current.asset_style_id; let asset_style_label = current.asset_style_label; let asset_style_prompt = current.asset_style_prompt; let generate_click_sound = current.generate_click_sound; match session.current_turn { 0 => { theme_text = if quick_fill_requested { MATCH3D_DEFAULT_THEME.to_string() } else { parse_theme_answer(text).unwrap_or(theme_text) }; } 1 => { clear_count = if quick_fill_requested { clear_count } else { parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) .unwrap_or(clear_count) } .max(1); } _ => { difficulty = if quick_fill_requested { difficulty } else { parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty) } .clamp(1, 10); } } Match3DConfigJson { theme_text, reference_image_src, clear_count, difficulty, asset_style_id, asset_style_label, asset_style_prompt, generate_click_sound, } } pub(super) fn resolve_config_or_default( config: Option<&Match3DCreatorConfigRecord>, ) -> Match3DConfigJson { config .map(|config| Match3DConfigJson { theme_text: config.theme_text.clone(), reference_image_src: config.reference_image_src.clone(), clear_count: config.clear_count.max(1), difficulty: config.difficulty.clamp(1, 10), asset_style_id: config.asset_style_id.clone(), asset_style_label: config.asset_style_label.clone(), asset_style_prompt: config.asset_style_prompt.clone(), generate_click_sound: config.generate_click_sound, }) .unwrap_or_else(|| Match3DConfigJson { theme_text: MATCH3D_DEFAULT_THEME.to_string(), reference_image_src: None, clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, difficulty: MATCH3D_DEFAULT_DIFFICULTY, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, }) } pub(super) fn normalize_optional_text(value: Option<&str>) -> Option { value .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option { serde_json::to_string(config).ok() } pub(super) fn build_seed_text( payload: &CreateMatch3DAgentSessionRequest, config: &Match3DConfigJson, ) -> String { payload .seed_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| { format!( "{}题材,消除{}次,难度{}", config.theme_text, config.clear_count, config.difficulty ) }) } fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { format!( "已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。", config.theme_text, config.clear_count, config.clear_count.saturating_mul(3), config.difficulty ) } pub(super) fn build_match3d_assistant_reply_for_turn( config: &Match3DConfigJson, current_turn: u32, ) -> String { match current_turn { 0 => MATCH3D_QUESTION_THEME.to_string(), 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), 2 => MATCH3D_QUESTION_DIFFICULTY.to_string(), _ => build_match3d_assistant_reply(config), } } pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 { match current_turn { 0 => 0, 1 => 33, 2 => 66, _ => 100, } } fn parse_theme_answer(text: &str) -> Option { for marker in ["题材", "主题"] { if let Some((_, value)) = text.split_once(marker) { let normalized = value .trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace()) .split_whitespace() .next() .unwrap_or_default() .trim_matches(['。', ',', ',', ';', ';']) .to_string(); if !normalized.is_empty() { return Some(normalized); } } } let trimmed = text.trim(); if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit()) { return Some(trimmed.to_string()); } None } fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option { for keyword in keywords { if let Some(index) = text.find(keyword) { let suffix = &text[index + keyword.len()..]; if let Some(value) = first_positive_integer(suffix) { return Some(value); } } } first_positive_integer(text) } fn first_positive_integer(text: &str) -> Option { let mut digits = String::new(); for ch in text.chars() { if ch.is_ascii_digit() { digits.push(ch); } else if !digits.is_empty() { break; } } digits.parse::().ok().filter(|value| *value > 0) } pub(super) fn normalize_tags(tags: Vec) -> Vec { let mut result: Vec = Vec::new(); for tag in tags { let trimmed = normalize_match3d_tag(tag.as_str()); if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) { result.push(trimmed); } if result.len() >= 6 { break; } } result } fn normalize_optional_match3d_text(value: Option) -> Option { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } async fn generate_match3d_draft_plan( state: &AppState, config: &Match3DConfigJson, ) -> Match3DGeneratedDraftPlan { let Some(llm_client) = state .creative_agent_gpt5_client() .or_else(|| state.llm_client()) else { return fallback_match3d_draft_plan(config); }; let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。"; let gameplay_item_count = resolve_match3d_gameplay_item_count(config); let generated_item_count = resolve_match3d_generated_item_count(config); let user_prompt = format!( "题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字;itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小;soundPrompt 只作为历史字段保留,可返回空字符串。", config.theme_text, gameplay_item_count, generated_item_count ); let response = llm_client .request_text( LlmTextRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) .with_responses_api(), ) .await; match response { Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config) .unwrap_or_else(|| fallback_match3d_draft_plan(config)), Err(error) => { tracing::warn!( provider = MATCH3D_AGENT_PROVIDER, theme_text = config.theme_text.as_str(), error = %error, "抓大鹅草稿生成计划失败,降级使用本地生成计划" ); fallback_match3d_draft_plan(config) } } } pub(super) fn parse_match3d_draft_plan( raw: &str, config: &Match3DConfigJson, ) -> Option { let raw = raw.trim(); let json_text = if let Some(start) = raw.find('{') && let Some(end) = raw.rfind('}') && end > start { &raw[start..=end] } else { raw }; let value = serde_json::from_str::(json_text).ok()?; let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?); if game_name.is_empty() { return None; } let tags = value .get("tags") .and_then(Value::as_array) .map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str))) .unwrap_or_default(); let fallback = fallback_match3d_draft_plan(config); let summary = value .get("summary") .or_else(|| value.get("description")) .or_else(|| value.get("workSummary")) .or_else(|| value.get("work_summary")) .and_then(Value::as_str) .map(normalize_match3d_work_summary) .filter(|value| !value.is_empty()) .unwrap_or(fallback.metadata.summary); let items = value .get("items") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| { let name = normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?); if name.is_empty() { return None; } let item_size = item .get("itemSize") .or_else(|| item.get("item_size")) .or_else(|| item.get("size")) .and_then(Value::as_str) .map(normalize_match3d_item_size) .filter(|value| !value.is_empty()) .unwrap_or_else(|| infer_match3d_item_size(&name)); let sound_prompt = item .get("soundPrompt") .or_else(|| item.get("sound_prompt")) .and_then(Value::as_str) .map(normalize_match3d_audio_prompt) .filter(|value| !value.is_empty()) .unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name)); Some(Match3DGeneratedItemPlan { name, item_size, sound_prompt, }) }) .collect::>() }) .unwrap_or_default(); let background_prompt = value .get("backgroundPrompt") .or_else(|| value.get("background_prompt")) .and_then(Value::as_str) .map(normalize_match3d_background_prompt) .filter(|value| !value.is_empty()) .unwrap_or(fallback.background_prompt); Some(Match3DGeneratedDraftPlan { metadata: Match3DGeneratedWorkMetadata { game_name, summary, tags: normalize_match3d_tag_candidates(tags), }, items: normalize_match3d_item_plan(config, items), background_prompt, }) } #[cfg(test)] pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option { let config = Match3DConfigJson { theme_text: MATCH3D_DEFAULT_THEME.to_string(), reference_image_src: None, clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, difficulty: MATCH3D_DEFAULT_DIFFICULTY, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, }; parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata) } fn normalize_match3d_game_name(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) .chars() .filter(|character| !character.is_control()) .take(16) .collect::() .trim() .to_string() } fn normalize_match3d_work_summary(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”']) .split_whitespace() .collect::>() .join("") .chars() .filter(|character| !character.is_control()) .take(80) .collect::() .trim() .to_string() } pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata { let theme = theme_text.trim(); let normalized_theme = if theme.is_empty() { "主题" } else { theme }; Match3DGeneratedWorkMetadata { game_name: format!("{normalized_theme}抓大鹅"), summary: normalize_match3d_work_summary( format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(), ), tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]), } } fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan { let metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); let items = fallback_match3d_item_names(config.theme_text.as_str()) .into_iter() .take(resolve_match3d_generated_item_count(config)) .map(|name| Match3DGeneratedItemPlan { item_size: infer_match3d_item_size(&name), sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), name, }) .collect::>(); Match3DGeneratedDraftPlan { background_prompt: build_fallback_match3d_background_prompt(config), metadata, items, } }