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 platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde_json::{Value, json}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, generated_asset_sheets::{ apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, normalize_generated_image_asset_mime, }, http_error::AppError, openai_image_generation::{ build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, request_context::RequestContext, state::AppState, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"]; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2; const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { tile_type: JumpHopTileType, source_atlas_cell: String, bytes: Vec, } pub async fn create_jump_hop_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; validate_workspace_request(&request_context, &payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("jump-hop-session-"); let now = current_utc_micros(); let draft = build_jump_hop_draft(&payload); let session = JumpHopSessionSnapshotResponse { session_id, owner_user_id, status: JumpHopGenerationStatus::Draft, draft: Some(draft), created_at: format_timestamp_micros(now), updated_at: format_timestamp_micros(now), }; Ok(json_success_body( Some(&request_context), JumpHopSessionResponse { session: state .spacetime_client() .create_jump_hop_session(session) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?, }, )) } pub async fn get_jump_hop_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .get_jump_hop_session(session_id, owner_user_id) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopSessionResponse { session }, )) } pub async fn execute_jump_hop_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let mut payload = payload; maybe_generate_jump_hop_assets( &state, &request_context, session_id.as_str(), owner_user_id.as_str(), &mut payload, ) .await?; let response = state .spacetime_client() .execute_jump_hop_action(session_id, owner_user_id, payload) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), response)) } pub async fn publish_jump_hop_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .publish_jump_hop_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopWorkMutationResponse { item: work }, )) } pub async fn list_jump_hop_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let works = state .spacetime_client() .list_jump_hop_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopWorksResponse { items: works.into_iter().map(|work| work.summary).collect(), }, )) } pub async fn get_jump_hop_runtime_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .get_jump_hop_runtime_work(profile_id) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopWorkDetailResponse { item: work }, )) } pub async fn start_jump_hop_run( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; let owner_user_id = principal.subject().to_string(); let principal_kind = principal.kind().as_str(); let run = state .spacetime_client() .start_jump_hop_run(payload, owner_user_id.clone()) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; record_work_play_start_after_success( &state, &request_context, build_jump_hop_work_play_tracking_draft( &principal, run.profile_id.clone(), JUMP_HOP_RUNTIME_RUNS_ROUTE, ) .owner_user_id(run.owner_user_id.clone()) .run_id(run.run_id.clone()) .profile_id(run.profile_id.clone()) .extra(json!({ "runStatus": run.status, "principalKind": principal_kind, })), ) .await; Ok(json_success_body( Some(&request_context), JumpHopRunResponse { run }, )) } pub async fn jump_hop_run_jump( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; let owner_user_id = principal.subject().to_string(); let run = state .spacetime_client() .jump_hop_run_jump(run_id, owner_user_id, payload) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopJumpResponse { run }, )) } pub async fn restart_jump_hop_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; let owner_user_id = principal.subject().to_string(); let run = state .spacetime_client() .restart_jump_hop_run(run_id, owner_user_id, payload) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopRunResponse { run }, )) } pub async fn list_jump_hop_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let gallery = state .spacetime_client() .list_jump_hop_gallery() .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), gallery)) } pub async fn get_jump_hop_gallery_detail( State(state): State, Path(public_work_code): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?; let work = state .spacetime_client() .get_jump_hop_gallery_detail(public_work_code) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopGalleryDetailResponse { item: work }, )) } async fn maybe_generate_jump_hop_assets( state: &AppState, request_context: &RequestContext, _session_id: &str, owner_user_id: &str, payload: &mut JumpHopActionRequest, ) -> Result<(), Response> { if !matches!(payload.action_type, JumpHopActionType::CompileDraft) { return Ok(()); } if payload.character_asset.is_some() && payload.tile_atlas_asset.is_some() && payload .tile_assets .as_ref() .is_some_and(|assets| !assets.is_empty()) { return Ok(()); } let profile_id = payload .profile_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) .map(ToString::to_string) .unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-")); payload.profile_id = Some(profile_id.clone()); let settings = require_openai_image_settings(state) .map(|settings| { settings.with_external_api_audit_context( request_context, Some(owner_user_id.to_string()), Some(profile_id.clone()), ) }) .map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; let http_client = build_openai_image_http_client(&settings).map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; let character_prompt = payload .character_prompt .as_deref() .unwrap_or("俯视角可爱主角,透明背景"); let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集"); let character_generated = create_openai_image_generation( &http_client, &settings, character_prompt, Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), "1024*1024", 1, &[], "跳一跳角色资产生成失败", ) .await .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; let character_image = character_generated .images .into_iter() .next() .ok_or_else(|| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "跳一跳角色资产生成成功但未返回图片。", })), ) })?; let character_asset = persist_jump_hop_generated_image_asset( state, owner_user_id, profile_id.as_str(), "character", character_prompt, character_image, LegacyAssetPrefix::JumpHopAssets, 768, 768, request_context, ) .await?; let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt); let tile_generated = create_openai_image_generation( &http_client, &settings, sheet_prompt.as_str(), Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), "1024*1024", 1, &[], "跳一跳地块图集生成失败", ) .await .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "跳一跳地块图集生成成功但未返回图片。", })), ) })?; let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; let tile_atlas_asset = persist_jump_hop_generated_image_asset( state, owner_user_id, profile_id.as_str(), "tile-atlas", tile_prompt, tile_image, LegacyAssetPrefix::JumpHopAssets, 1024, 1024, request_context, ) .await?; let mut tile_assets = Vec::with_capacity(tile_slices.len()); for (index, tile_slice) in tile_slices.into_iter().enumerate() { tile_assets.push( persist_jump_hop_tile_asset( state, owner_user_id, profile_id.as_str(), index, tile_slice, request_context, ) .await?, ); } payload.character_asset = Some(character_asset); payload.tile_atlas_asset = Some(tile_atlas_asset); payload.tile_assets = Some(tile_assets); payload.cover_composite = payload.cover_composite.clone().or_else(|| { Some(format!( "/generated-jump-hop-assets/{profile_id}/cover-composite.png" )) }); Ok(()) } fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String { let subject_text = tile_prompt.trim(); let subject_text = if subject_text.is_empty() { "等距立体地块图集" } else { subject_text }; let cell_plan = [ "第1行第1列:start 起点地块", "第1行第2列:normal 普通地块", "第1行第3列:target 目标地块", "第2行第1列:finish 终点地块", "第2行第2列:bonus 奖励地块", "第2行第3列:accent 视觉强调地块", ] .join(";"); format!( "生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。" ) } fn slice_jump_hop_tile_atlas( image: &crate::openai_image_generation::DownloadedOpenAiImage, ) -> Result, AppError> { let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": JUMP_HOP_CREATION_PROVIDER, "message": format!("跳一跳地块图集解码失败:{error}"), })) })?; let source = apply_generated_asset_sheet_green_screen_alpha(source); let width = source.width(); let height = source.height(); let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS; if cell_width == 0 || cell_height == 0 { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": JUMP_HOP_CREATION_PROVIDER, "message": "跳一跳地块图集尺寸过小,无法切割。", })), ); } let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len()); for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() { let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; let cropped = source.crop_imm( x0, y0, x1.saturating_sub(x0).max(1), y1.saturating_sub(y0).max(1), ); let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); let mut cursor = std::io::Cursor::new(Vec::new()); cleaned .write_to(&mut cursor, image::ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": JUMP_HOP_CREATION_PROVIDER, "message": format!("跳一跳地块图集切割失败:{error}"), })) })?; slices.push(JumpHopTileAtlasSlice { tile_type: jump_hop_tile_type_by_index(index), source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), bytes: cursor.into_inner(), }); } Ok(slices) } fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { match index { 0 => JumpHopTileType::Start, 1 => JumpHopTileType::Normal, 2 => JumpHopTileType::Target, 3 => JumpHopTileType::Finish, 4 => JumpHopTileType::Bonus, _ => JumpHopTileType::Accent, } } fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str { match tile_type { JumpHopTileType::Start => "tile-start", JumpHopTileType::Normal => "tile-normal", JumpHopTileType::Target => "tile-target", JumpHopTileType::Finish => "tile-finish", JumpHopTileType::Bonus => "tile-bonus", JumpHopTileType::Accent => "tile-accent", } } #[allow(clippy::too_many_arguments)] async fn persist_jump_hop_tile_asset( state: &AppState, owner_user_id: &str, profile_id: &str, tile_index: usize, tile_slice: JumpHopTileAtlasSlice, request_context: &RequestContext, ) -> Result { let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type); let image = crate::openai_image_generation::DownloadedOpenAiImage { bytes: tile_slice.bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }; let persisted = persist_jump_hop_generated_image_asset( state, owner_user_id, profile_id, slot, &format!( "跳一跳地块切片 {}:{}", tile_index + 1, tile_slice.source_atlas_cell ), image, LegacyAssetPrefix::JumpHopAssets, 256, 192, request_context, ) .await?; Ok(JumpHopTileAsset { tile_type: tile_slice.tile_type, image_src: persisted.image_src, image_object_key: persisted.image_object_key, asset_object_id: persisted.asset_object_id, source_atlas_cell: tile_slice.source_atlas_cell, visual_width: 256, visual_height: 192, top_surface_radius: 42.0, landing_radius: 34.0, }) } async fn persist_jump_hop_generated_image_asset( state: &AppState, owner_user_id: &str, profile_id: &str, slot: &str, prompt: &str, image: crate::openai_image_generation::DownloadedOpenAiImage, prefix: LegacyAssetPrefix, width: u32, height: u32, request_context: &RequestContext, ) -> Result { let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str()); let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { prefix, path_segments: vec![profile_id.to_string(), slot.to_string()], file_stem: "image".to_string(), image: GeneratedImageAssetDataUrl { format: image_format, bytes: image.bytes, }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(format!("jump-hop-{slot}")), owner_user_id: Some(owner_user_id.to_string()), entity_kind: Some("jump_hop_work".to_string()), entity_id: Some(profile_id.to_string()), slot: Some(slot.to_string()), provider: Some("vector-engine".to_string()), task_id: None, }, extra_metadata: BTreeMap::new(), }) .map_err(|error| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, 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 oss_client = state.oss_client().ok_or_else(|| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })), ) })?; let http_client = reqwest::Client::new(); let put_result = oss_client .put_object(&http_client, prepared.request) .await .map_err(|error| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": error.to_string(), })), ) })?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await .map_err(|error| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": error.to_string(), })), ) })?; let now_micros = current_utc_micros(); let asset_object_input = build_asset_object_upsert_input( generate_asset_object_id(now_micros), head.bucket, head.object_key.clone(), AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, head.etag, format!("jump-hop-{slot}"), None, Some(owner_user_id.to_string()), Some(profile_id.to_string()), Some(profile_id.to_string()), now_micros, ) .map_err(|error| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "message": error.to_string(), })), ) })?; let asset_object = state .spacetime_client() .confirm_asset_object(asset_object_input) .await .map_err(|error| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })), ) })?; let binding_input = build_asset_entity_binding_input( generate_asset_binding_id(now_micros), asset_object.asset_object_id.clone(), "jump_hop_work".to_string(), profile_id.to_string(), slot.to_string(), format!("jump-hop-{slot}"), Some(owner_user_id.to_string()), Some(profile_id.to_string()), now_micros, ) .map_err(|error| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-entity-binding", "message": error.to_string(), })), ) })?; state .spacetime_client() .bind_asset_object_to_entity(binding_input) .await .map_err(|error| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })), ) })?; Ok(JumpHopCharacterAsset { asset_id: format!("{profile_id}-{slot}-{now_micros}"), image_src: put_result.legacy_public_path, image_object_key: head.object_key, asset_object_id: asset_object.asset_object_id, generation_provider: "vector-engine".to_string(), prompt: prompt.to_string(), width, height, }) } fn build_jump_hop_work_play_tracking_draft( principal: &RuntimePrincipal, work_id: impl Into, source_route: &'static str, ) -> WorkPlayTrackingDraft { WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route) } fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, work_title: payload.work_title.trim().to_string(), work_description: payload.work_description.trim().to_string(), theme_tags: normalize_tags(payload.theme_tags.clone()), difficulty: payload.difficulty.clone(), style_preset: payload.style_preset.clone(), character_prompt: payload.character_prompt.trim().to_string(), tile_prompt: payload.tile_prompt.trim().to_string(), end_mood_prompt: payload .end_mood_prompt .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), character_asset: None, tile_atlas_asset: None, tile_assets: Vec::new(), path: None, cover_composite: None, generation_status: JumpHopGenerationStatus::Draft, } } fn validate_workspace_request( request_context: &RequestContext, payload: &JumpHopWorkspaceCreateRequest, ) -> Result<(), Response> { ensure_non_empty(request_context, &payload.work_title, "workTitle")?; ensure_non_empty( request_context, &payload.character_prompt, "characterPrompt", )?; ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?; if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID { return Err(jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": JUMP_HOP_PROVIDER, "message": "templateId 必须为 jump-hop", })), )); } Ok(()) } fn ensure_non_empty( request_context: &RequestContext, value: &str, field: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(jump_hop_error_response( request_context, JUMP_HOP_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": JUMP_HOP_PROVIDER, "field": field, "message": format!("{field} 不能为空"), })), )); } Ok(()) } fn normalize_tags(tags: Vec) -> Vec { let mut normalized = Vec::new(); for tag in tags { let tag = tag.trim(); if tag.is_empty() || normalized.iter().any(|item| item == tag) { continue; } normalized.push(tag.to_string()); if normalized.len() >= 6 { break; } } normalized } fn jump_hop_json( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, Response> { payload.map_err(|error| { jump_hop_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": error.to_string(), })), ) }) } fn map_jump_hop_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("必须") => { StatusCode::BAD_REQUEST } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn jump_hop_error_response( request_context: &RequestContext, provider: &str, 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_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static("jump-hop")), ); response } fn current_utc_micros() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[test] fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() { let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块"); assert!(prompt.contains("2行*3列")); assert!(prompt.contains("第1行第1列:start 起点地块")); assert!(prompt.contains("第2行第3列:accent 视觉强调地块")); assert!(!prompt.contains("每个物品生成")); assert!(!prompt.contains("不同视图")); } #[test] fn jump_hop_tile_atlas_slices_one_png_per_tile_type() { let width = 300; let height = 200; let colors = [ [220, 24, 24, 255], [240, 150, 32, 255], [248, 220, 72, 255], [52, 168, 84, 255], [38, 132, 255, 255], [156, 92, 220, 255], ]; let mut atlas = image::RgbaImage::new(width, height); for row in 0..2 { for col in 0..3 { let color = image::Rgba(colors[row * 3 + col]); for y in row as u32 * 100..(row as u32 + 1) * 100 { for x in col as u32 * 100..(col as u32 + 1) * 100 { atlas.put_pixel(x, y, color); } } } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(atlas) .write_to(&mut encoded, image::ImageFormat::Png) .expect("atlas should encode"); let image = crate::openai_image_generation::DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len()); for (index, slice) in slices.iter().enumerate() { assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index)); assert_eq!( slice.source_atlas_cell, format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1) ); let decoded = image::load_from_memory(slice.bytes.as_slice()) .expect("tile slice should decode") .to_rgba8(); assert!( decoded.pixels().any(|pixel| pixel.0 == colors[index]), "第 {index} 个地块切片应保留对应格子的主体颜色" ); } } }