use super::*; use crate::mapper::{ map_puzzle_clear_agent_session_procedure_result, map_puzzle_clear_gallery_card_view_row, map_puzzle_clear_run_procedure_result, map_puzzle_clear_work_procedure_result, map_puzzle_clear_works_procedure_result, }; use module_puzzle_clear::{PUZZLE_CLEAR_PROFILE_ID_PREFIX, PUZZLE_CLEAR_RUN_ID_PREFIX}; use shared_contracts::puzzle_clear::{ PuzzleClearActionRequest, PuzzleClearActionResponse, PuzzleClearActionType, PuzzleClearGenerationStatus, PuzzleClearImageAsset, PuzzleClearNextLevelRequest, PuzzleClearPatternGroup, PuzzleClearRetryLevelRequest, PuzzleClearRuntimeSnapshotResponse, PuzzleClearSessionSnapshotResponse, PuzzleClearStartRunRequest, PuzzleClearSwapRequest, PuzzleClearTimeUpRequest, PuzzleClearWorkProfileResponse, PuzzleClearWorkSummaryResponse, }; use shared_kernel::build_prefixed_uuid_id; const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear"; const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消"; const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 128; const PUZZLE_CLEAR_ASSET_OBJECT_ID_PREFIX: &str = "assetobj_"; impl SpacetimeClient { pub async fn create_puzzle_clear_session( &self, session: PuzzleClearSessionSnapshotResponse, ) -> Result { let draft = session.draft.clone().ok_or_else(|| { SpacetimeClientError::validation_failed("puzzle-clear session 缺少 draft") })?; let procedure_input = PuzzleClearAgentSessionCreateInput { session_id: session.session_id, owner_user_id: session.owner_user_id, work_title: draft.work_title, work_description: draft.work_description, theme_prompt: draft.theme_prompt, generate_board_background: draft.generate_board_background, board_background_asset_json: draft .board_background_asset .as_ref() .map(json_string) .transpose()?, board_background_prompt: draft.board_background_prompt, created_at_micros: current_unix_micros(), }; self.call_after_connect( "create_puzzle_clear_agent_session", move |connection, sender| { connection .procedures() .create_puzzle_clear_agent_session_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_agent_session_procedure_result); send_once(&sender, mapped); }); }, ) .await } pub async fn get_puzzle_clear_session( &self, session_id: String, owner_user_id: String, ) -> Result { let procedure_input = PuzzleClearAgentSessionGetInput { session_id, owner_user_id, }; self.call_after_connect( "get_puzzle_clear_agent_session", move |connection, sender| { connection.procedures().get_puzzle_clear_agent_session_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_agent_session_procedure_result); send_once(&sender, mapped); }, ); }, ) .await } pub async fn execute_puzzle_clear_action( &self, session_id: String, owner_user_id: String, author_display_name: String, payload: PuzzleClearActionRequest, ) -> Result { let current = self .get_puzzle_clear_session(session_id.clone(), owner_user_id.clone()) .await?; let (procedure, _) = build_puzzle_clear_action_plan( ¤t, &owner_user_id, &author_display_name, &payload, current_unix_micros(), )?; let (session, work) = match procedure { PuzzleClearActionProcedure::Compile(input) => { let profile_id = input.profile_id.clone(); let session = self.compile_puzzle_clear_draft(input).await?; let work = self .get_puzzle_clear_work_profile(profile_id, owner_user_id) .await .ok(); (session, work) } PuzzleClearActionProcedure::Update(input) => { let work = self.update_puzzle_clear_work(input).await?; let session = apply_puzzle_clear_work_to_session(current, &work); (session, Some(work)) } }; Ok(PuzzleClearActionResponse { action_type: payload.action_type, session, work, }) } pub async fn compile_puzzle_clear_draft( &self, procedure_input: PuzzleClearDraftCompileInput, ) -> Result { self.call_after_connect("compile_puzzle_clear_draft", move |connection, sender| { connection.procedures().compile_puzzle_clear_draft_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_agent_session_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn mark_puzzle_clear_generation_failed( &self, session_id: String, owner_user_id: String, author_display_name: String, payload: PuzzleClearActionRequest, ) -> Result { let current = self .get_puzzle_clear_session(session_id, owner_user_id.clone()) .await?; let procedure_input = build_failed_compile_input( ¤t, &owner_user_id, &author_display_name, &payload, current_unix_micros(), )?; self.compile_puzzle_clear_draft(procedure_input).await } pub async fn get_puzzle_clear_work_profile( &self, profile_id: String, owner_user_id: String, ) -> Result { let procedure_input = PuzzleClearWorkGetInput { profile_id, owner_user_id, }; self.call_after_connect( "get_puzzle_clear_work_profile", move |connection, sender| { connection.procedures().get_puzzle_clear_work_profile_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_work_procedure_result); send_once(&sender, mapped); }, ); }, ) .await } pub async fn update_puzzle_clear_work( &self, procedure_input: PuzzleClearWorkUpdateInput, ) -> Result { self.call_after_connect("update_puzzle_clear_work", move |connection, sender| { connection.procedures().update_puzzle_clear_work_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_work_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn publish_puzzle_clear_work( &self, profile_id: String, owner_user_id: String, ) -> Result { let procedure_input = PuzzleClearWorkPublishInput { profile_id, owner_user_id, published_at_micros: current_unix_micros(), }; self.call_after_connect("publish_puzzle_clear_work", move |connection, sender| { connection.procedures().publish_puzzle_clear_work_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_work_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn list_puzzle_clear_works( &self, owner_user_id: String, ) -> Result, SpacetimeClientError> { let procedure_input = PuzzleClearWorksListInput { owner_user_id, published_only: false, }; self.call_after_connect("list_puzzle_clear_works", move |connection, sender| { connection.procedures().list_puzzle_clear_works_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_works_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn get_puzzle_clear_runtime_work( &self, profile_id: String, ) -> Result { let work = self .get_puzzle_clear_work_profile(profile_id, String::new()) .await?; validate_puzzle_clear_runtime_ready(&work)?; Ok(work) } pub async fn start_puzzle_clear_run( &self, payload: PuzzleClearStartRunRequest, owner_user_id: String, ) -> Result { let profile_id = payload.profile_id; let work = self .get_puzzle_clear_work_profile(profile_id.clone(), String::new()) .await?; validate_puzzle_clear_runtime_ready(&work)?; let run_id = build_prefixed_uuid_id(PUZZLE_CLEAR_RUN_ID_PREFIX); let procedure_input = PuzzleClearRunStartInput { client_event_id: format!("{run_id}:start"), run_id, owner_user_id, profile_id, started_at_ms: current_unix_micros().div_euclid(1000), }; self.start_puzzle_clear_run_with_input(procedure_input) .await } pub async fn start_puzzle_clear_run_with_input( &self, procedure_input: PuzzleClearRunStartInput, ) -> Result { self.call_after_connect( "start_puzzle_clear_runtime_run", move |connection, sender| { connection.procedures().start_puzzle_clear_runtime_run_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_run_procedure_result); send_once(&sender, mapped); }, ); }, ) .await } pub async fn get_puzzle_clear_run( &self, run_id: String, owner_user_id: String, ) -> Result { let procedure_input = PuzzleClearRunGetInput { run_id, owner_user_id, }; self.call_after_connect("get_puzzle_clear_runtime_run", move |connection, sender| { connection.procedures().get_puzzle_clear_runtime_run_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_run_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn swap_puzzle_clear_cards( &self, run_id: String, owner_user_id: String, payload: PuzzleClearSwapRequest, ) -> Result { let procedure_input = PuzzleClearRunSwapInput { run_id, owner_user_id, from_row: payload.from_row, from_col: payload.from_col, to_row: payload.to_row, to_col: payload.to_col, client_action_id: payload.client_action_id, swapped_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect("swap_puzzle_clear_cards", move |connection, sender| { connection.procedures().swap_puzzle_clear_cards_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_run_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn retry_puzzle_clear_level( &self, run_id: String, owner_user_id: String, payload: PuzzleClearRetryLevelRequest, ) -> Result { let procedure_input = PuzzleClearRunRetryLevelInput { run_id, owner_user_id, client_action_id: payload.client_action_id, restarted_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect("retry_puzzle_clear_level_run", move |connection, sender| { connection.procedures().retry_puzzle_clear_level_run_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_run_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn advance_puzzle_clear_next_level( &self, run_id: String, owner_user_id: String, payload: PuzzleClearNextLevelRequest, ) -> Result { let procedure_input = PuzzleClearRunNextLevelInput { run_id, owner_user_id, client_action_id: payload.client_action_id, started_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect( "advance_puzzle_clear_next_level", move |connection, sender| { connection .procedures() .advance_puzzle_clear_next_level_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_run_procedure_result); send_once(&sender, mapped); }); }, ) .await } pub async fn mark_puzzle_clear_level_time_up( &self, run_id: String, owner_user_id: String, payload: PuzzleClearTimeUpRequest, ) -> Result { let procedure_input = PuzzleClearRunTimeUpInput { run_id, owner_user_id, client_action_id: payload.client_action_id, occurred_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect( "mark_puzzle_clear_level_time_up", move |connection, sender| { connection .procedures() .mark_puzzle_clear_level_time_up_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_puzzle_clear_run_procedure_result); send_once(&sender, mapped); }); }, ) .await } pub async fn list_puzzle_clear_gallery( &self, ) -> Result, SpacetimeClientError> { self.read_after_connect("list_puzzle_clear_gallery", move |connection| { let mut items = connection .db() .puzzle_clear_gallery_card_view() .iter() .collect::>(); items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); Ok(items .into_iter() .map(map_puzzle_clear_gallery_card_view_row) .collect()) }) .await } } enum PuzzleClearActionProcedure { Compile(PuzzleClearDraftCompileInput), Update(PuzzleClearWorkUpdateInput), } #[derive(Clone, Copy)] enum PuzzleClearDraftMergeScope { CompileDraft, RegenerateAtlas, UpdateWorkMeta, UpdateBoardBackground, } fn build_puzzle_clear_action_plan( current: &PuzzleClearSessionSnapshotResponse, owner_user_id: &str, author_display_name: &str, payload: &PuzzleClearActionRequest, now_micros: i64, ) -> Result<(PuzzleClearActionProcedure, PuzzleClearDraftResponse), SpacetimeClientError> { let scope = match payload.action_type { PuzzleClearActionType::CompileDraft => PuzzleClearDraftMergeScope::CompileDraft, PuzzleClearActionType::RegenerateAtlas => PuzzleClearDraftMergeScope::RegenerateAtlas, PuzzleClearActionType::UpdateWorkMeta => PuzzleClearDraftMergeScope::UpdateWorkMeta, PuzzleClearActionType::UpdateBoardBackground => { PuzzleClearDraftMergeScope::UpdateBoardBackground } }; let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?; let profile_id = resolve_puzzle_clear_profile_id( &draft, &payload.action_type, payload.profile_id.as_deref(), )?; draft.profile_id = Some(profile_id.clone()); let procedure = match payload.action_type { PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas => { PuzzleClearActionProcedure::Compile(build_compile_input( current, owner_user_id, author_display_name, &profile_id, &mut draft, now_micros, )?) } PuzzleClearActionType::UpdateWorkMeta | PuzzleClearActionType::UpdateBoardBackground => PuzzleClearActionProcedure::Update( build_update_input(owner_user_id, &profile_id, &draft, now_micros)?, ), }; Ok((procedure, draft)) } fn merge_action_into_draft( draft: Option, payload: &PuzzleClearActionRequest, scope: PuzzleClearDraftMergeScope, ) -> Result { let mut draft = draft.unwrap_or_else(default_draft); if matches!( scope, PuzzleClearDraftMergeScope::CompileDraft | PuzzleClearDraftMergeScope::UpdateWorkMeta | PuzzleClearDraftMergeScope::RegenerateAtlas ) { if let Some(value) = payload .work_title .as_ref() .and_then(|value| non_empty_str(value)) { draft.work_title = value; } if let Some(value) = payload.work_description.as_ref() { draft.work_description = value.trim().to_string(); } if let Some(value) = payload .theme_prompt .as_ref() .and_then(|value| non_empty_str(value)) { draft.theme_prompt = value; } if let Some(value) = payload .board_background_prompt .as_ref() .and_then(|value| non_empty_str(value)) { draft.board_background_prompt = value; } } if matches!( scope, PuzzleClearDraftMergeScope::CompileDraft | PuzzleClearDraftMergeScope::UpdateBoardBackground | PuzzleClearDraftMergeScope::RegenerateAtlas ) { if let Some(value) = payload.generate_board_background { draft.generate_board_background = value; } if payload.board_background_asset.is_some() { draft.board_background_asset = payload.board_background_asset.clone(); } } if matches!( scope, PuzzleClearDraftMergeScope::CompileDraft | PuzzleClearDraftMergeScope::RegenerateAtlas ) { if let Some(asset) = payload.atlas_asset.clone() { draft.atlas_asset = Some(asset); } if let Some(groups) = payload.pattern_groups.clone() { draft.pattern_groups = groups; } if let Some(cards) = payload.card_assets.clone() { draft.card_assets = cards; } if draft.pattern_groups.is_empty() { draft.pattern_groups = default_pattern_groups(); } draft.generation_status = PuzzleClearGenerationStatus::Ready; } if draft.work_title.trim().is_empty() || draft.theme_prompt.trim().is_empty() { return Err(SpacetimeClientError::validation_failed( "puzzle-clear 草稿需要标题和主题词", )); } Ok(draft) } fn build_compile_input( current: &PuzzleClearSessionSnapshotResponse, owner_user_id: &str, author_display_name: &str, profile_id: &str, draft: &mut PuzzleClearDraftResponse, now_micros: i64, ) -> Result { if draft.pattern_groups.is_empty() { draft.pattern_groups = default_pattern_groups(); } let atlas_asset = ensure_real_puzzle_clear_atlas_asset(draft.atlas_asset.as_ref())?; ensure_real_puzzle_clear_card_assets(&draft.card_assets)?; Ok(PuzzleClearDraftCompileInput { session_id: current.session_id.clone(), owner_user_id: owner_user_id.to_string(), profile_id: profile_id.to_string(), author_display_name: non_empty_str(author_display_name) .unwrap_or_else(|| "拼消消玩家".to_string()), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_prompt: draft.theme_prompt.clone(), board_background_prompt: draft.board_background_prompt.clone(), generate_board_background: draft.generate_board_background, board_background_asset_json: draft .board_background_asset .as_ref() .map(json_string) .transpose()?, atlas_asset_json: Some(json_string(atlas_asset)?), pattern_groups_json: Some(json_string(&draft.pattern_groups)?), card_assets_json: Some(json_string(&draft.card_assets)?), generation_status: Some("ready".to_string()), compiled_at_micros: now_micros, }) } fn build_failed_compile_input( current: &PuzzleClearSessionSnapshotResponse, owner_user_id: &str, author_display_name: &str, payload: &PuzzleClearActionRequest, now_micros: i64, ) -> Result { let mut draft = current.draft.clone().unwrap_or_else(default_draft); if let Some(value) = payload .work_title .as_ref() .and_then(|value| non_empty_str(value)) { draft.work_title = value; } if let Some(value) = payload.work_description.as_ref() { draft.work_description = value.trim().to_string(); } if let Some(value) = payload .theme_prompt .as_ref() .and_then(|value| non_empty_str(value)) { draft.theme_prompt = value; } if let Some(value) = payload .board_background_prompt .as_ref() .and_then(|value| non_empty_str(value)) { draft.board_background_prompt = value; } if let Some(value) = payload.generate_board_background { draft.generate_board_background = value; } if let Some(asset) = payload.board_background_asset.clone() { draft.board_background_asset = Some(asset); } if let Some(profile_id) = payload .profile_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { draft.profile_id = Some(profile_id.to_string()); } draft.generation_status = PuzzleClearGenerationStatus::Failed; let profile_id = resolve_puzzle_clear_profile_id( &draft, &PuzzleClearActionType::CompileDraft, draft.profile_id.as_deref(), )?; draft.profile_id = Some(profile_id.clone()); Ok(PuzzleClearDraftCompileInput { session_id: current.session_id.clone(), owner_user_id: owner_user_id.to_string(), profile_id, author_display_name: non_empty_str(author_display_name) .unwrap_or_else(|| "拼消消玩家".to_string()), work_title: non_empty_str(draft.work_title.as_str()) .unwrap_or_else(|| PUZZLE_CLEAR_TEMPLATE_NAME.to_string()), work_description: draft.work_description.trim().to_string(), theme_prompt: non_empty_str(draft.theme_prompt.as_str()) .unwrap_or_else(|| PUZZLE_CLEAR_TEMPLATE_NAME.to_string()), board_background_prompt: draft.board_background_prompt.clone(), generate_board_background: draft.generate_board_background, board_background_asset_json: draft .board_background_asset .as_ref() .map(json_string) .transpose()?, atlas_asset_json: None, pattern_groups_json: None, card_assets_json: None, generation_status: Some("failed".to_string()), compiled_at_micros: now_micros, }) } fn build_update_input( owner_user_id: &str, profile_id: &str, draft: &PuzzleClearDraftResponse, now_micros: i64, ) -> Result { Ok(PuzzleClearWorkUpdateInput { profile_id: profile_id.to_string(), owner_user_id: owner_user_id.to_string(), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_prompt: draft.theme_prompt.clone(), board_background_prompt: draft.board_background_prompt.clone(), generate_board_background: draft.generate_board_background, board_background_asset_json: draft .board_background_asset .as_ref() .map(json_string) .transpose()?, updated_at_micros: now_micros, }) } fn resolve_puzzle_clear_profile_id( draft: &PuzzleClearDraftResponse, action_type: &PuzzleClearActionType, payload_profile_id: Option<&str>, ) -> Result { if let Some(profile_id) = payload_profile_id .map(str::trim) .filter(|value| !value.is_empty()) { return Ok(profile_id.to_string()); } if let Some(profile_id) = draft .profile_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { return Ok(profile_id.to_string()); } if matches!(action_type, PuzzleClearActionType::CompileDraft) { return Ok(build_prefixed_uuid_id(PUZZLE_CLEAR_PROFILE_ID_PREFIX)); } Err(SpacetimeClientError::validation_failed( "puzzle-clear action 需要先完成 compile-draft", )) } fn apply_puzzle_clear_work_to_session( mut session: PuzzleClearSessionSnapshotResponse, work: &PuzzleClearWorkProfileResponse, ) -> PuzzleClearSessionSnapshotResponse { session.status = work.draft.generation_status.clone(); session.draft = Some(work.draft.clone()); session.updated_at = work.summary.updated_at.clone(); session } fn validate_puzzle_clear_runtime_ready( work: &PuzzleClearWorkProfileResponse, ) -> Result<(), SpacetimeClientError> { if work.summary.publication_status != "published" { return Err(SpacetimeClientError::validation_failed( "puzzle-clear runtime 只能启动已发布作品", )); } if work.summary.generation_status != PuzzleClearGenerationStatus::Ready { return Err(SpacetimeClientError::validation_failed( "puzzle-clear runtime 需要 ready 状态作品", )); } if work.card_assets.is_empty() || work.pattern_groups.is_empty() { return Err(SpacetimeClientError::validation_failed( "puzzle-clear runtime 缺少切片卡牌资产", )); } Ok(()) } fn ensure_real_puzzle_clear_atlas_asset( asset: Option<&PuzzleClearImageAsset>, ) -> Result<&PuzzleClearImageAsset, SpacetimeClientError> { let Some(asset) = asset else { return Err(SpacetimeClientError::validation_failed( "puzzle-clear atlas 缺少真实生成资产", )); }; if !is_real_puzzle_clear_asset( asset.asset_object_id.as_str(), asset.image_object_key.as_str(), asset.image_src.as_str(), ) { return Err(SpacetimeClientError::validation_failed( "puzzle-clear atlas 缺少真实生成资产", )); } Ok(asset) } fn ensure_real_puzzle_clear_card_assets( assets: &[PuzzleClearCardAsset], ) -> Result<(), SpacetimeClientError> { if assets.is_empty() { return Err(SpacetimeClientError::validation_failed( "puzzle-clear card assets 缺少真实生成资产", )); } if assets.iter().any(|asset| { !is_real_puzzle_clear_asset( asset.asset_object_id.as_str(), asset.image_object_key.as_str(), asset.image_src.as_str(), ) }) { return Err(SpacetimeClientError::validation_failed( "puzzle-clear card assets 缺少真实生成资产", )); } Ok(()) } fn is_real_puzzle_clear_asset( asset_object_id: &str, image_object_key: &str, image_src: &str, ) -> bool { asset_object_id.starts_with(PUZZLE_CLEAR_ASSET_OBJECT_ID_PREFIX) && image_object_key.starts_with("generated-puzzle-clear-assets/") && image_src.starts_with("/generated-puzzle-clear-assets/") } fn default_draft() -> PuzzleClearDraftResponse { PuzzleClearDraftResponse { template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), profile_id: None, work_title: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), work_description: String::new(), theme_prompt: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), board_background_prompt: String::new(), generate_board_background: true, board_background_asset: None, card_back_image_src: Some("/creation-type-references/puzzle.webp".to_string()), atlas_asset: None, pattern_groups: Vec::new(), card_assets: Vec::new(), generation_status: PuzzleClearGenerationStatus::Draft, } } fn default_pattern_groups() -> Vec { module_puzzle_clear::plan_puzzle_clear_pattern_groups(PUZZLE_CLEAR_ATLAS_CELL_SIZE) .unwrap_or_default() .into_iter() .map(|group| PuzzleClearPatternGroup { group_id: group.group_id, shape: group.shape.as_str().to_string(), width: group.width, height: group.height, atlas_x: group.atlas_x, atlas_y: group.atlas_y, atlas_width: group.atlas_width, atlas_height: group.atlas_height, }) .collect() } fn non_empty_str(value: &str) -> Option { let value = value.trim(); if value.is_empty() { None } else { Some(value.to_string()) } } fn json_string(value: &T) -> Result { serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed) } #[cfg(test)] mod tests { use super::*; const SESSION_ID: &str = "puzzle-clear-session-test"; const OWNER_USER_ID: &str = "user-test"; const NOW_MICROS: i64 = 1_780_000_000_000_000; #[test] fn puzzle_clear_compile_requires_real_atlas_assets_from_api_server() { let session = session_with_draft(draft_without_assets()); let payload = action(PuzzleClearActionType::CompileDraft); let error = match build_puzzle_clear_action_plan( &session, OWNER_USER_ID, "拼消消玩家", &payload, NOW_MICROS, ) { Ok(_) => panic!("compile-draft should not synthesize placeholder atlas assets"), Err(error) => error, }; assert!(error.to_string().contains("atlas")); assert!(error.to_string().contains("真实生成资产")); } #[test] fn puzzle_clear_failure_writeback_does_not_require_generated_assets() { let session = session_with_draft(draft_without_assets()); let input = build_failed_compile_input( &session, OWNER_USER_ID, "拼消消玩家", &PuzzleClearActionRequest { action_type: PuzzleClearActionType::CompileDraft, profile_id: None, work_title: None, work_description: Some("VectorEngine 素材 atlas 生成失败".to_string()), theme_prompt: None, board_background_prompt: None, generate_board_background: None, board_background_asset: None, atlas_asset: None, pattern_groups: None, card_assets: None, }, NOW_MICROS, ) .expect("failed writeback input should be buildable without assets"); assert_eq!(input.session_id, SESSION_ID); assert_eq!(input.owner_user_id, OWNER_USER_ID); assert_eq!(input.generation_status.as_deref(), Some("failed")); assert!(input.atlas_asset_json.is_none()); assert!(input.pattern_groups_json.is_none()); assert!(input.card_assets_json.is_none()); assert_eq!(input.work_title, "水果拼消消"); assert_eq!(input.theme_prompt, "水果"); assert_eq!(input.work_description, "VectorEngine 素材 atlas 生成失败"); } fn session_with_draft(draft: PuzzleClearDraftResponse) -> PuzzleClearSessionSnapshotResponse { PuzzleClearSessionSnapshotResponse { session_id: SESSION_ID.to_string(), owner_user_id: OWNER_USER_ID.to_string(), status: draft.generation_status.clone(), draft: Some(draft), created_at: "2026-05-30T00:00:00Z".to_string(), updated_at: "2026-05-30T00:00:00Z".to_string(), } } fn draft_without_assets() -> PuzzleClearDraftResponse { PuzzleClearDraftResponse { template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), profile_id: None, work_title: "水果拼消消".to_string(), work_description: String::new(), theme_prompt: "水果".to_string(), board_background_prompt: String::new(), generate_board_background: false, board_background_asset: None, card_back_image_src: Some("/creation-type-references/puzzle.webp".to_string()), atlas_asset: None, pattern_groups: Vec::new(), card_assets: Vec::new(), generation_status: PuzzleClearGenerationStatus::Draft, } } fn action(action_type: PuzzleClearActionType) -> PuzzleClearActionRequest { PuzzleClearActionRequest { action_type, profile_id: None, work_title: None, work_description: None, theme_prompt: None, board_background_prompt: None, generate_board_background: None, board_background_asset: None, atlas_asset: None, pattern_groups: None, card_assets: None, } } }