use super::*; use crate::mapper::{ map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row, map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result, }; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkProfileResponse, }; use shared_kernel::build_prefixed_uuid_id; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; impl SpacetimeClient { pub async fn create_jump_hop_session( &self, session: JumpHopSessionSnapshotResponse, ) -> Result { let draft = session.draft.clone().ok_or_else(|| { SpacetimeClientError::validation_failed("jump-hop session 缺少 draft") })?; let theme_tags_json = Some(json_string(&draft.theme_tags)?); let config_json = Some(build_config_json(&draft)?); let work_title = draft.work_title.clone(); let work_description = draft.work_description.clone(); let procedure_input = JumpHopAgentSessionCreateInput { session_id: session.session_id, owner_user_id: session.owner_user_id, seed_text: work_title.clone(), work_title, work_description, theme_tags_json, welcome_message_text: "跳一跳草稿已准备好。".to_string(), config_json, created_at_micros: current_unix_micros(), }; self.call_after_connect( "create_jump_hop_agent_session", move |connection, sender| { connection.procedures().create_jump_hop_agent_session_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_agent_session_procedure_result); send_once(&sender, mapped); }, ); }, ) .await } pub async fn get_jump_hop_session( &self, session_id: String, owner_user_id: String, ) -> Result { let procedure_input = JumpHopAgentSessionGetInput { session_id, owner_user_id, }; self.call_after_connect("get_jump_hop_agent_session", move |connection, sender| { connection.procedures().get_jump_hop_agent_session_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_agent_session_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn execute_jump_hop_action( &self, session_id: String, owner_user_id: String, payload: JumpHopActionRequest, ) -> Result { let current = self .get_jump_hop_session(session_id.clone(), owner_user_id.clone()) .await?; let (procedure, _) = build_jump_hop_action_plan(¤t, &owner_user_id, &payload, current_unix_micros())?; let (session, work) = match procedure { JumpHopActionProcedure::Compile(input) => { let profile_id = input.profile_id.clone(); let session = self.compile_jump_hop_draft(input).await?; let work = self .get_jump_hop_work_profile(profile_id, owner_user_id) .await .ok(); (session, work) } JumpHopActionProcedure::Update(input) => { let work = self.update_jump_hop_work(input).await?; let session = apply_jump_hop_work_to_session(current, &work); (session, Some(work)) } }; Ok(JumpHopActionResponse { action_type: payload.action_type, session, work, }) } pub async fn compile_jump_hop_draft( &self, procedure_input: JumpHopDraftCompileInput, ) -> Result { self.call_after_connect("compile_jump_hop_draft", move |connection, sender| { connection.procedures().compile_jump_hop_draft_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_agent_session_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn get_jump_hop_work_profile( &self, profile_id: String, owner_user_id: String, ) -> Result { let procedure_input = JumpHopWorkGetInput { profile_id, owner_user_id, }; self.call_after_connect("get_jump_hop_work_profile", move |connection, sender| { connection.procedures().get_jump_hop_work_profile_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_work_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn update_jump_hop_work( &self, procedure_input: JumpHopWorkUpdateInput, ) -> Result { self.call_after_connect("update_jump_hop_work", move |connection, sender| { connection .procedures() .update_jump_hop_work_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_work_procedure_result); send_once(&sender, mapped); }); }) .await } pub async fn publish_jump_hop_work( &self, profile_id: String, owner_user_id: String, ) -> Result { let procedure_input = JumpHopWorkPublishInput { profile_id, owner_user_id, published_at_micros: current_unix_micros(), }; self.call_after_connect("publish_jump_hop_work", move |connection, sender| { connection.procedures().publish_jump_hop_work_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_work_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn list_jump_hop_works( &self, owner_user_id: String, ) -> Result, SpacetimeClientError> { let procedure_input = JumpHopWorksListInput { owner_user_id, published_only: false, }; self.call_after_connect("list_jump_hop_works", move |connection, sender| { connection .procedures() .list_jump_hop_works_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_works_procedure_result); send_once(&sender, mapped); }); }) .await } pub async fn get_jump_hop_runtime_work( &self, profile_id: String, ) -> Result { let work = self .get_jump_hop_work_profile(profile_id, String::new()) .await?; validate_jump_hop_runtime_ready(&work)?; Ok(work) } pub async fn start_jump_hop_run( &self, payload: JumpHopStartRunRequest, owner_user_id: String, ) -> Result { let profile_id = payload.profile_id; let work = self .get_jump_hop_work_profile(profile_id.clone(), String::new()) .await?; validate_jump_hop_runtime_ready(&work)?; let run_id = build_prefixed_uuid_id("jump-hop-run-"); let procedure_input = JumpHopRunStartInput { 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_jump_hop_run_with_input(procedure_input).await } pub async fn start_jump_hop_run_with_input( &self, procedure_input: JumpHopRunStartInput, ) -> Result { self.call_after_connect("start_jump_hop_run", move |connection, sender| { connection .procedures() .start_jump_hop_run_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); }); }) .await } pub async fn get_jump_hop_run( &self, run_id: String, owner_user_id: String, ) -> Result { let procedure_input = JumpHopRunGetInput { run_id, owner_user_id, }; self.call_after_connect("get_jump_hop_run", move |connection, sender| { connection .procedures() .get_jump_hop_run_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); }); }) .await } pub async fn jump_hop_run_jump( &self, run_id: String, owner_user_id: String, payload: JumpHopJumpRequest, ) -> Result { let procedure_input = JumpHopRunJumpInput { run_id, owner_user_id, charge_ms: payload.charge_ms, client_event_id: payload.client_event_id, jumped_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect("jump_hop_jump", move |connection, sender| { connection .procedures() .jump_hop_jump_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); }); }) .await } pub async fn restart_jump_hop_run( &self, run_id: String, owner_user_id: String, payload: JumpHopRestartRunRequest, ) -> Result { let procedure_input = JumpHopRunRestartInput { source_run_id: run_id, next_run_id: build_prefixed_uuid_id("jump-hop-run-"), owner_user_id, client_action_id: payload.client_action_id, restarted_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect("restart_jump_hop_run", move |connection, sender| { connection .procedures() .restart_jump_hop_run_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); }); }) .await } pub async fn list_jump_hop_gallery( &self, ) -> Result { self.read_after_connect("list_jump_hop_gallery", move |connection| { let mut items = connection .db() .jump_hop_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(JumpHopGalleryResponse { items: items .into_iter() .map(map_jump_hop_gallery_card_view_row) .collect(), has_more: false, next_cursor: None, }) }) .await } pub async fn get_jump_hop_gallery_detail( &self, public_work_code: String, ) -> Result { let gallery = self.list_jump_hop_gallery().await?; let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str()); let card = gallery .items .into_iter() .find(|item| { normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code }) .ok_or_else(|| { SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()) })?; self.get_jump_hop_work_profile(card.profile_id, String::new()) .await } } fn validate_jump_hop_runtime_ready( work: &JumpHopWorkProfileResponse, ) -> Result<(), SpacetimeClientError> { let status = work.summary.publication_status.trim().to_ascii_lowercase(); if status != "published" { return Err(SpacetimeClientError::validation_failed( "jump-hop runtime 只能启动已发布作品", )); } if work.summary.generation_status != JumpHopGenerationStatus::Ready { return Err(SpacetimeClientError::validation_failed( "jump-hop runtime 需要 ready 状态作品", )); } validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?; validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; if work.tile_assets.is_empty() { return Err(SpacetimeClientError::validation_failed( "jump-hop runtime 缺少地块资产", )); } for (index, asset) in work.tile_assets.iter().enumerate() { if asset.image_src.trim().is_empty() || asset.image_object_key.trim().is_empty() || asset.asset_object_id.trim().is_empty() { return Err(SpacetimeClientError::validation_failed(format!( "jump-hop runtime 地块资产 #{index} 不完整" ))); } } if work.path.platforms.is_empty() { return Err(SpacetimeClientError::validation_failed( "jump-hop runtime 缺少可玩路径", )); } Ok(()) } fn validate_jump_hop_character_asset_ready( asset: &JumpHopCharacterAsset, field: &str, ) -> Result<(), SpacetimeClientError> { if asset.image_src.trim().is_empty() || asset.image_object_key.trim().is_empty() || asset.asset_object_id.trim().is_empty() { return Err(SpacetimeClientError::validation_failed(format!( "jump-hop runtime {field} 不完整" ))); } if asset.generation_provider.trim().is_empty() || asset.generation_provider == "deterministic-placeholder" { return Err(SpacetimeClientError::validation_failed(format!( "jump-hop runtime {field} 不是可用真实生成资产" ))); } Ok(()) } fn normalize_jump_hop_public_work_code(value: &str) -> String { value .chars() .filter(|character| character.is_ascii_alphanumeric()) .map(|character| character.to_ascii_uppercase()) .collect() } enum JumpHopActionProcedure { Compile(JumpHopDraftCompileInput), Update(JumpHopWorkUpdateInput), } #[derive(Clone, Copy)] enum JumpHopDraftMergeScope { CompileDraft, RegenerateCharacter, RegenerateTiles, UpdateWorkMeta, UpdateDifficulty, } #[derive(Clone, Copy)] enum JumpHopAssetRefresh { Preserve, Character, Tiles, } fn build_jump_hop_action_plan( current: &JumpHopSessionSnapshotResponse, owner_user_id: &str, payload: &JumpHopActionRequest, now_micros: i64, ) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> { let scope = match payload.action_type { JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft, JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter, JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles, JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta, JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty, }; let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?; let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?; draft.profile_id = Some(profile_id.clone()); let procedure = match payload.action_type { JumpHopActionType::CompileDraft => JumpHopActionProcedure::Compile(build_compile_input( current, owner_user_id, &profile_id, &mut draft, JumpHopAssetRefresh::Preserve, now_micros, )?), JumpHopActionType::RegenerateCharacter => { JumpHopActionProcedure::Compile(build_compile_input( current, owner_user_id, &profile_id, &mut draft, JumpHopAssetRefresh::Character, now_micros, )?) } JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input( current, owner_user_id, &profile_id, &mut draft, JumpHopAssetRefresh::Tiles, now_micros, )?), JumpHopActionType::UpdateWorkMeta | JumpHopActionType::UpdateDifficulty => { JumpHopActionProcedure::Update(build_update_input( owner_user_id, &profile_id, &draft, &payload.action_type, now_micros, )?) } }; Ok((procedure, draft)) } fn merge_action_into_draft( draft: Option, payload: &JumpHopActionRequest, scope: JumpHopDraftMergeScope, ) -> Result { let mut draft = draft.unwrap_or_else(default_draft); if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::UpdateWorkMeta ) { if let Some(value) = payload .work_title .as_ref() .filter(|value| !value.trim().is_empty()) { draft.work_title = value.trim().to_string(); } if let Some(value) = payload.work_description.as_ref() { draft.work_description = value.trim().to_string(); } if let Some(value) = payload.theme_tags.clone() { draft.theme_tags = normalize_jump_hop_tags(value); } } if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::UpdateDifficulty ) { if let Some(value) = payload.difficulty.clone() { draft.difficulty = value; } } if matches!(scope, JumpHopDraftMergeScope::CompileDraft) { if let Some(value) = payload.style_preset.clone() { draft.style_preset = value; } if payload.end_mood_prompt.is_some() { draft.end_mood_prompt = payload .end_mood_prompt .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); } } if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter ) { if let Some(value) = payload .character_prompt .as_ref() .filter(|value| !value.trim().is_empty()) { draft.character_prompt = value.trim().to_string(); } } if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles ) { if let Some(value) = payload .tile_prompt .as_ref() .filter(|value| !value.trim().is_empty()) { draft.tile_prompt = value.trim().to_string(); } } 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()); } if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter ) { if let Some(asset) = payload.character_asset.clone() { draft.character_asset = Some(asset); } } if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles ) { if let Some(asset) = payload.tile_atlas_asset.clone() { draft.tile_atlas_asset = Some(asset); } if let Some(assets) = payload.tile_assets.clone() { draft.tile_assets = assets; } } if let Some(value) = payload .cover_composite .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { draft.cover_composite = Some(value.to_string()); } if draft.work_title.trim().is_empty() { return Err(SpacetimeClientError::validation_failed( "jump-hop work_title 不能为空", )); } Ok(draft) } fn build_compile_input( current: &JumpHopSessionSnapshotResponse, owner_user_id: &str, profile_id: &str, draft: &mut JumpHopDraftResponse, refresh: JumpHopAssetRefresh, now_micros: i64, ) -> Result { let force_character = matches!(refresh, JumpHopAssetRefresh::Character); let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles); if force_character { draft.character_asset = None; } if force_tiles { draft.tile_atlas_asset = None; draft.tile_assets.clear(); } let character_asset = draft.character_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed( "jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object", ) })?; let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed( "jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object", ) })?; let tile_assets = if draft.tile_assets.is_empty() { return Err(SpacetimeClientError::validation_failed( "jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object", )); } else { draft.tile_assets.clone() }; let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros); draft.cover_composite = cover_composite.clone(); draft.generation_status = JumpHopGenerationStatus::Ready; Ok(JumpHopDraftCompileInput { session_id: current.session_id.clone(), owner_user_id: owner_user_id.to_string(), profile_id: profile_id.to_string(), author_display_name: "跳一跳玩家".to_string(), seed_text: draft.work_title.clone(), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: Some(json_string(&draft.theme_tags)?), theme_text: Some(draft.work_title.clone()), difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()), style_preset: Some(style_to_str(&draft.style_preset).to_string()), character_prompt: Some(draft.character_prompt.clone()), tile_prompt: Some(draft.tile_prompt.clone()), end_mood_prompt: draft.end_mood_prompt.clone(), character_asset_json: Some(json_string(&character_asset)?), tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?), tile_assets_json: Some(json_string(&tile_assets)?), cover_composite, generation_status: Some("ready".to_string()), compiled_at_micros: now_micros, }) } fn build_update_input( owner_user_id: &str, profile_id: &str, draft: &JumpHopDraftResponse, action_type: &JumpHopActionType, now_micros: i64, ) -> Result { Ok(JumpHopWorkUpdateInput { 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_tags_json: json_string(&draft.theme_tags)?, difficulty: matches!(action_type, JumpHopActionType::UpdateDifficulty) .then(|| difficulty_to_str(&draft.difficulty).to_string()), style_preset: None, cover_image_src: None, cover_composite: None, updated_at_micros: now_micros, }) } fn resolve_jump_hop_profile_id( draft: &JumpHopDraftResponse, action_type: &JumpHopActionType, ) -> Result { 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, JumpHopActionType::CompileDraft) { return Ok(build_prefixed_uuid_id("jump-hop-profile-")); } Err(SpacetimeClientError::validation_failed( "jump-hop action 需要先完成 compile-draft", )) } fn apply_jump_hop_work_to_session( mut session: JumpHopSessionSnapshotResponse, work: &JumpHopWorkProfileResponse, ) -> JumpHopSessionSnapshotResponse { session.status = work.draft.generation_status.clone(); session.draft = Some(work.draft.clone()); session.updated_at = work.summary.updated_at.clone(); session } fn normalize_jump_hop_tags(tags: Vec) -> Vec { tags.into_iter() .map(|tag| tag.trim().to_string()) .filter(|tag| !tag.is_empty()) .take(8) .collect() } fn default_draft() -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, work_title: JUMP_HOP_TEMPLATE_NAME.to_string(), work_description: "俯视角跳跃闯关".to_string(), theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()], difficulty: JumpHopDifficulty::Standard, style_preset: JumpHopStylePreset::MinimalBlocks, character_prompt: "俯视角可爱主角,透明背景".to_string(), tile_prompt: "等距立体地块图集".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, tile_assets: Vec::new(), path: None, cover_composite: None, generation_status: JumpHopGenerationStatus::Draft, } } fn build_config_json(draft: &JumpHopDraftResponse) -> Result { serde_json::to_string(&serde_json::json!({ "themeText": draft.work_title, "difficulty": difficulty_to_str(&draft.difficulty), "stylePreset": style_to_str(&draft.style_preset), "characterPrompt": draft.character_prompt, "tilePrompt": draft.tile_prompt, "endMoodPrompt": draft.end_mood_prompt.clone().unwrap_or_default(), })) .map_err(SpacetimeClientError::validation_failed) } fn ensure_character_asset( existing: Option, profile_id: &str, prompt: &str, force_new: bool, now_micros: i64, ) -> JumpHopCharacterAsset { if !force_new { if let Some(asset) = existing { return asset; } } let revision = force_new.then_some(now_micros); let suffix = asset_revision_suffix(revision); JumpHopCharacterAsset { asset_id: format!("{profile_id}-character{suffix}"), image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"), image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"), asset_object_id: format!("{profile_id}-character{suffix}-object"), generation_provider: "deterministic-placeholder".to_string(), prompt: prompt.to_string(), width: 768, height: 768, } } fn ensure_tile_atlas_asset( existing: Option, profile_id: &str, prompt: &str, force_new: bool, now_micros: i64, ) -> JumpHopCharacterAsset { if !force_new { if let Some(asset) = existing { return asset; } } let revision = force_new.then_some(now_micros); let suffix = asset_revision_suffix(revision); JumpHopCharacterAsset { asset_id: format!("{profile_id}-tile-atlas{suffix}"), image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"), generation_provider: "deterministic-placeholder".to_string(), prompt: prompt.to_string(), width: 1024, height: 1024, } } fn ensure_tile_assets( existing: Vec, profile_id: &str, force_new: bool, now_micros: i64, ) -> Vec { if !force_new && !existing.is_empty() { return existing; } let suffix = asset_revision_suffix(force_new.then_some(now_micros)); [ JumpHopTileType::Start, JumpHopTileType::Normal, JumpHopTileType::Target, JumpHopTileType::Finish, JumpHopTileType::Bonus, JumpHopTileType::Accent, ] .into_iter() .enumerate() .map(|(index, tile_type)| JumpHopTileAsset { tile_type, image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"), image_object_key: format!( "generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png" ), asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"), source_atlas_cell: format!("cell-{index}{suffix}"), visual_width: 256, visual_height: 192, top_surface_radius: 42.0, landing_radius: 34.0, }) .collect() } fn resolve_cover_composite( draft: &JumpHopDraftResponse, profile_id: &str, refresh: JumpHopAssetRefresh, now_micros: i64, ) -> Option { if matches!(refresh, JumpHopAssetRefresh::Preserve) { if let Some(value) = draft .cover_composite .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { return Some(value.to_string()); } } let suffix = asset_revision_suffix( (!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros), ); Some(format!( "/generated-jump-hop-assets/{profile_id}/cover-composite{suffix}.png" )) } fn asset_revision_suffix(revision: Option) -> String { revision .filter(|value| *value > 0) .map(|value| format!("-{value}")) .unwrap_or_default() } fn json_string(value: &T) -> Result { serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed) } fn difficulty_to_str(value: &JumpHopDifficulty) -> &'static str { match value { JumpHopDifficulty::Easy => "easy", JumpHopDifficulty::Standard => "standard", JumpHopDifficulty::Advanced => "advanced", JumpHopDifficulty::Challenge => "challenge", } } fn style_to_str(value: &JumpHopStylePreset) -> &'static str { match value { JumpHopStylePreset::MinimalBlocks => "minimal-blocks", JumpHopStylePreset::PaperToy => "paper-toy", JumpHopStylePreset::NeonGlass => "neon-glass", JumpHopStylePreset::ForestStone => "forest-stone", JumpHopStylePreset::FutureMetal => "future-metal", JumpHopStylePreset::Custom => "custom", } } #[cfg(test)] mod tests { use super::*; use shared_contracts::jump_hop::JumpHopActionType; const SESSION_ID: &str = "jump-hop-session-test"; const OWNER_USER_ID: &str = "user-test"; const PROFILE_ID: &str = "jump-hop-profile-test"; const NOW_MICROS: i64 = 1_763_456_789_000_000; #[test] fn jump_hop_action_compile_draft_builds_compile_input_with_assets() { let session = session_with_draft(draft_without_assets()); let payload = action(JumpHopActionType::CompileDraft); let (plan, draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("compile-draft should build plan"); let JumpHopActionProcedure::Compile(input) = plan else { panic!("compile-draft should call compile_jump_hop_draft"); }; assert_eq!(input.session_id, SESSION_ID); assert_eq!(input.owner_user_id, OWNER_USER_ID); assert_eq!(input.generation_status.as_deref(), Some("ready")); assert!( input .character_asset_json .as_deref() .unwrap_or("") .contains("-character") ); assert!( input .tile_atlas_asset_json .as_deref() .unwrap_or("") .contains("-tile-atlas") ); assert!( input .tile_assets_json .as_deref() .unwrap_or("") .contains("tile-0-object") ); assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready); } #[test] fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() { let session = session_with_draft(draft_with_assets()); let mut payload = action(JumpHopActionType::RegenerateCharacter); payload.character_prompt = Some("新的主角提示词".to_string()); let (plan, _draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("regenerate-character should build plan"); let JumpHopActionProcedure::Compile(input) = plan else { panic!("regenerate-character should call compile_jump_hop_draft"); }; assert!( !input .character_asset_json .as_deref() .unwrap_or("") .contains("old-character") ); assert!( input .character_asset_json .as_deref() .unwrap_or("") .contains(&NOW_MICROS.to_string()) ); assert!( input .tile_atlas_asset_json .as_deref() .unwrap_or("") .contains("old-tile-atlas") ); assert!( input .tile_assets_json .as_deref() .unwrap_or("") .contains("old-normal-tile") ); } #[test] fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() { let session = session_with_draft(draft_with_assets()); let mut payload = action(JumpHopActionType::RegenerateTiles); payload.tile_prompt = Some("新的地块提示词".to_string()); let (plan, _draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("regenerate-tiles should build plan"); let JumpHopActionProcedure::Compile(input) = plan else { panic!("regenerate-tiles should call compile_jump_hop_draft"); }; assert!( input .character_asset_json .as_deref() .unwrap_or("") .contains("old-character") ); assert!( !input .tile_atlas_asset_json .as_deref() .unwrap_or("") .contains("old-tile-atlas") ); assert!( !input .tile_assets_json .as_deref() .unwrap_or("") .contains("old-normal-tile") ); assert!( input .tile_atlas_asset_json .as_deref() .unwrap_or("") .contains(&NOW_MICROS.to_string()) ); assert!( input .tile_assets_json .as_deref() .unwrap_or("") .contains(&NOW_MICROS.to_string()) ); } #[test] fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() { let session = session_with_draft(draft_with_assets()); let mut payload = action(JumpHopActionType::UpdateWorkMeta); payload.work_title = Some("新标题".to_string()); payload.work_description = Some("新描述".to_string()); payload.theme_tags = Some(vec![" A ".to_string(), "B".to_string()]); payload.character_prompt = Some("不应影响角色资产".to_string()); let (plan, draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("update-work-meta should build plan"); let JumpHopActionProcedure::Update(input) = plan else { panic!("update-work-meta should call update_jump_hop_work"); }; assert_eq!(input.profile_id, PROFILE_ID); assert_eq!(input.work_title, "新标题"); assert_eq!(input.work_description, "新描述"); assert!(input.difficulty.is_none()); assert!(input.style_preset.is_none()); assert_eq!(draft.character_prompt, "旧角色提示词"); } #[test] fn jump_hop_action_update_difficulty_builds_update_input_without_asset_compile() { let session = session_with_draft(draft_with_assets()); let mut payload = action(JumpHopActionType::UpdateDifficulty); payload.difficulty = Some(JumpHopDifficulty::Challenge); let (plan, draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("update-difficulty should build plan"); let JumpHopActionProcedure::Update(input) = plan else { panic!("update-difficulty should call update_jump_hop_work"); }; assert_eq!(input.difficulty.as_deref(), Some("challenge")); assert!(input.style_preset.is_none()); assert_eq!( draft .character_asset .as_ref() .map(|asset| asset.asset_id.as_str()), Some("old-character") ); assert_eq!( draft .tile_assets .first() .map(|asset| asset.asset_object_id.as_str()), Some("old-normal-tile-object") ); } /// 构造不携带资产覆盖的 JumpHop action,单测按需再覆盖字段。 fn action(action_type: JumpHopActionType) -> JumpHopActionRequest { JumpHopActionRequest { action_type, profile_id: None, work_title: None, work_description: None, theme_tags: None, difficulty: None, style_preset: None, character_prompt: None, tile_prompt: None, end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, tile_assets: None, cover_composite: None, } } fn session_with_draft(draft: JumpHopDraftResponse) -> JumpHopSessionSnapshotResponse { JumpHopSessionSnapshotResponse { 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-19T00:00:00Z".to_string(), updated_at: "2026-05-19T00:00:00Z".to_string(), } } fn draft_without_assets() -> JumpHopDraftResponse { JumpHopDraftResponse { profile_id: None, ..base_draft() } } fn draft_with_assets() -> JumpHopDraftResponse { JumpHopDraftResponse { profile_id: Some(PROFILE_ID.to_string()), character_asset: Some(JumpHopCharacterAsset { asset_id: "old-character".to_string(), image_src: "/generated-jump-hop-assets/old-character.png".to_string(), image_object_key: "generated-jump-hop-assets/old-character.png".to_string(), asset_object_id: "old-character-object".to_string(), generation_provider: "old-provider".to_string(), prompt: "旧角色提示词".to_string(), width: 768, height: 768, }), tile_atlas_asset: Some(JumpHopCharacterAsset { asset_id: "old-tile-atlas".to_string(), image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(), image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(), asset_object_id: "old-tile-atlas-object".to_string(), generation_provider: "old-provider".to_string(), prompt: "旧地块提示词".to_string(), width: 1024, height: 1024, }), tile_assets: vec![JumpHopTileAsset { tile_type: JumpHopTileType::Normal, image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(), image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(), asset_object_id: "old-normal-tile-object".to_string(), source_atlas_cell: "old-cell".to_string(), visual_width: 256, visual_height: 192, top_surface_radius: 42.0, landing_radius: 34.0, }], path: Some(sample_jump_hop_path()), cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()), generation_status: JumpHopGenerationStatus::Ready, ..base_draft() } } fn base_draft() -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, work_title: "旧标题".to_string(), work_description: "旧描述".to_string(), theme_tags: vec!["旧标签".to_string()], difficulty: JumpHopDifficulty::Standard, style_preset: JumpHopStylePreset::MinimalBlocks, character_prompt: "旧角色提示词".to_string(), tile_prompt: "旧地块提示词".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, tile_assets: Vec::new(), path: None, cover_composite: None, generation_status: JumpHopGenerationStatus::Draft, } } fn sample_jump_hop_path() -> JumpHopPath { JumpHopPath { seed: "jump-hop-test".to_string(), difficulty: JumpHopDifficulty::Standard, platforms: vec![JumpHopPlatform { platform_id: "platform-0".to_string(), tile_type: JumpHopTileType::Start, x: 0.0, y: 0.0, width: 92.0, height: 70.0, landing_radius: 34.0, perfect_radius: 14.0, score_value: 10, }], finish_index: 0, camera_preset: "portrait-isometric-follow".to_string(), scoring: JumpHopScoring { charge_to_distance_ratio: 0.018, max_charge_ms: 1_200, hit_bonus: 10, perfect_bonus: 20, }, } } }