This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions

View File

@@ -28,7 +28,9 @@ use webp::Encoder as WebpEncoder;
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
asset_billing::{
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
},
auth::AuthenticatedAccessToken,
custom_world_result_prompts::{
build_result_entity_system_prompt, build_result_entity_user_prompt,
@@ -115,6 +117,12 @@ pub(crate) struct CustomWorldCoverUploadRequest {
crop_rect: CustomWorldCoverCropRect,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldOpeningCgGenerateRequest {
profile: Value,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeneratedAssetResponse {
@@ -133,6 +141,38 @@ struct GeneratedAssetResponse {
actual_prompt: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeneratedOpeningCgResponse {
opening_cg: CustomWorldOpeningCgProfileResponse,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CustomWorldOpeningCgProfileResponse {
id: String,
status: &'static str,
storyboard_image_src: String,
storyboard_asset_id: String,
video_src: String,
video_asset_id: String,
poster_image_src: Option<String>,
poster_asset_id: Option<String>,
storyboard_prompt: String,
video_prompt: String,
image_model: &'static str,
video_model: String,
aspect_ratio: &'static str,
image_size: &'static str,
video_resolution: &'static str,
duration_seconds: u32,
point_cost: u64,
estimated_wait_minutes: u32,
generated_at: String,
updated_at: String,
error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCustomWorldSceneImage {
pub image_src: String,
@@ -317,6 +357,22 @@ struct DownloadedRemoteImage {
}
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
const OPENING_CG_POINTS_COST: u64 = 80;
const OPENING_CG_ESTIMATED_WAIT_MINUTES: u32 = 10;
const OPENING_CG_IMAGE_SIZE_LABEL: &str = "2k";
const OPENING_CG_STORYBOARD_IMAGE_SIZE: &str = "2048x1152";
const OPENING_CG_VIDEO_PROMPT: &str = "利用参考图作为故事板,生成一段连贯的动画,没有旁白";
const OPENING_CG_VIDEO_RESOLUTION: &str = "480p";
const OPENING_CG_VIDEO_RATIO: &str = "16:9";
const OPENING_CG_VIDEO_DURATION_SECONDS: u32 = 15;
const OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS: u64 = 600_000;
const OPENING_CG_ASPECT_RATIO: &str = "16:9";
const OPENING_CG_STORYBOARD_ASSET_KIND: &str = "custom_world_opening_cg_storyboard";
const OPENING_CG_VIDEO_ASSET_KIND: &str = "custom_world_opening_cg_video";
const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
struct CoverPromptContext {
opening_act_title: String,
@@ -336,6 +392,39 @@ struct NormalizedSceneImageRequest {
reference_image_src: Option<String>,
}
struct NormalizedOpeningCgRequest {
profile_id: Option<String>,
world_name: String,
opening_cg_id: String,
storyboard_prompt: String,
video_prompt: String,
player_role_image_src: String,
opening_scene_image_src: String,
}
struct ArkVideoSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
model: String,
}
struct GeneratedOpeningCgStoryboard {
image_src: String,
asset_id: String,
}
struct GeneratedOpeningCgVideo {
video_src: String,
asset_id: String,
}
struct DownloadedRemoteVideo {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
#[derive(Debug)]
struct NormalizedCropRect {
left: u32,
@@ -884,6 +973,119 @@ pub async fn upload_custom_world_cover_image(
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn generate_custom_world_opening_cg(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldOpeningCgGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-opening-cg",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let normalized = normalize_opening_cg_request(&payload.profile)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
require_openai_image_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
require_ark_video_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let opening_cg_id = normalized.opening_cg_id.clone();
let generated = execute_billable_asset_operation_with_cost(
&state,
&owner_user_id,
"custom_world_opening_cg",
opening_cg_id.as_str(),
OPENING_CG_POINTS_COST,
async {
let image_settings = require_openai_image_settings(&state)?;
let image_http_client = build_openai_image_http_client(&image_settings)?;
let video_settings = require_ark_video_settings(&state)?;
let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?;
let player_role_reference = resolve_reference_image_as_data_url(
&state,
&image_http_client,
normalized.player_role_image_src.as_str(),
"playerRoleImageSrc",
)
.await?;
let opening_scene_reference = resolve_reference_image_as_data_url(
&state,
&image_http_client,
normalized.opening_scene_image_src.as_str(),
"openingSceneImageSrc",
)
.await?;
let storyboard = generate_opening_cg_storyboard(
&state,
&owner_user_id,
&image_http_client,
&image_settings,
&normalized,
&[player_role_reference, opening_scene_reference],
)
.await?;
let storyboard_reference = resolve_reference_image_as_data_url(
&state,
&image_http_client,
storyboard.image_src.as_str(),
"storyboardImageSrc",
)
.await?;
let video = generate_opening_cg_video(
&state,
&owner_user_id,
&video_http_client,
&video_settings,
&normalized,
storyboard_reference.as_str(),
)
.await?;
let generated_at = current_utc_iso_text();
Ok(CustomWorldOpeningCgProfileResponse {
id: opening_cg_id.clone(),
status: "ready",
storyboard_image_src: storyboard.image_src,
storyboard_asset_id: storyboard.asset_id,
video_src: video.video_src,
video_asset_id: video.asset_id,
poster_image_src: None,
poster_asset_id: None,
storyboard_prompt: normalized.storyboard_prompt.clone(),
video_prompt: normalized.video_prompt.clone(),
image_model: GPT_IMAGE_2_MODEL,
video_model: video_settings.model,
aspect_ratio: OPENING_CG_ASPECT_RATIO,
image_size: OPENING_CG_IMAGE_SIZE_LABEL,
video_resolution: OPENING_CG_VIDEO_RESOLUTION,
duration_seconds: OPENING_CG_VIDEO_DURATION_SECONDS,
point_cost: OPENING_CG_POINTS_COST,
estimated_wait_minutes: OPENING_CG_ESTIMATED_WAIT_MINUTES,
generated_at: generated_at.clone(),
updated_at: generated_at,
error_message: None,
})
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
GeneratedOpeningCgResponse {
opening_cg: generated,
},
))
}
async fn persist_custom_world_asset(
state: &AppState,
owner_user_id: &str,
@@ -974,6 +1176,337 @@ async fn persist_custom_world_asset(
Ok(response)
}
async fn generate_opening_cg_storyboard(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
normalized: &NormalizedOpeningCgRequest,
reference_images: &[String],
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
let generated = create_openai_image_generation(
http_client,
settings,
normalized.storyboard_prompt.as_str(),
None,
OPENING_CG_STORYBOARD_IMAGE_SIZE,
1,
reference_images,
"开局 CG 故事板生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "开局 CG 故事板生成成功但未返回图片。",
}))
})?;
let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("storyboard.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_STORYBOARD_SLOT,
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
task_id: Some(generated.task_id.clone()),
prompt: Some(normalized.storyboard_prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
Ok(GeneratedOpeningCgStoryboard {
image_src: asset.image_src,
asset_id,
})
}
async fn generate_opening_cg_video(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
normalized: &NormalizedOpeningCgRequest,
storyboard_reference_data_url: &str,
) -> Result<GeneratedOpeningCgVideo, AppError> {
let upstream_task_id = create_ark_storyboard_to_video_task(
http_client,
settings,
normalized.video_prompt.as_str(),
storyboard_reference_data_url,
)
.await?;
let video_url =
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
.await?;
let downloaded =
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
let video_src = persist_opening_cg_video_asset(
state,
owner_user_id,
normalized,
asset_id.as_str(),
Some(upstream_task_id.clone()),
downloaded,
)
.await?;
Ok(GeneratedOpeningCgVideo {
video_src,
asset_id,
})
}
async fn persist_opening_cg_video_asset(
state: &AppState,
owner_user_id: &str,
normalized: &NormalizedOpeningCgRequest,
asset_id: &str,
source_job_id: Option<String>,
video: DownloadedRemoteVideo,
) -> Result<String, AppError> {
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("opening.{}", video.extension),
content_type: video.mime_type,
body: video.bytes,
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_VIDEO_SLOT,
source_job_id,
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.to_string(),
source_type: "generated".to_string(),
model: Some("ark-seedance".to_string()),
size: Some(format!(
"{}:{}:{}s",
OPENING_CG_VIDEO_RESOLUTION,
OPENING_CG_VIDEO_RATIO,
OPENING_CG_VIDEO_DURATION_SECONDS
)),
task_id: None,
prompt: Some(normalized.video_prompt.clone()),
actual_prompt: None,
},
)
.await?;
Ok(asset.image_src)
}
async fn create_ark_storyboard_to_video_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
storyboard_reference_data_url: &str,
) -> Result<String, AppError> {
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.base_url))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.model,
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": storyboard_reference_data_url,
},
"role": "reference_image",
}
],
"resolution": OPENING_CG_VIDEO_RESOLUTION,
"ratio": OPENING_CG_VIDEO_RATIO,
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
"watermark": false,
"audio": true,
"generate_audio": true,
"web_search": true,
"enable_web_search": true,
}))
.send()
.await
.map_err(|error| map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}")))?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"创建开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
extract_ark_task_id(&payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务未返回任务 id。",
}))
})
}
async fn wait_for_ark_content_generation_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
task_id: &str,
) -> Result<String, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let response = http_client
.get(format!(
"{}/contents/generations/tasks/{}",
settings.base_url, task_id
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}")))?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"查询开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
if let Some(video_url) = extract_video_url(&payload.payload) {
return Ok(video_url);
}
let normalized_status = normalize_generation_task_status(
extract_generation_task_status(&payload.payload).as_str(),
);
if is_completed_generation_task_status(normalized_status.as_str()) {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务完成但没有返回 video_url。",
"taskId": task_id,
})));
}
if is_failed_generation_task_status(normalized_status.as_str()) {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"开局 CG 视频任务执行失败。",
));
}
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
}
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频生成超时,请稍后重试。",
"taskId": task_id,
})))
}
async fn download_generated_video(
http_client: &reqwest::Client,
video_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteVideo, AppError> {
let response = http_client
.get(video_url)
.send()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("video/mp4")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": fallback_message,
"status": status.as_u16(),
})));
}
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
Ok(DownloadedRemoteVideo {
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
@@ -1225,6 +1758,176 @@ fn normalize_scene_image_request(
})
}
fn normalize_opening_cg_request(profile: &Value) -> Result<NormalizedOpeningCgRequest, AppError> {
let object = profile.as_object().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-opening-cg",
"message": "profile 必须是 JSON object",
}))
})?;
let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string());
let profile_id = read_string_field(object, "id");
let world_tone = read_string_field(object, "tone").ok_or_else(|| {
missing_opening_cg_field_error("世界基调缺失,无法生成开局 CG。")
})?;
let world_summary = read_string_field(object, "summary").ok_or_else(|| {
missing_opening_cg_field_error("世界概述缺失,无法生成开局 CG。")
})?;
let core_conflicts = read_string_array_field(object, "coreConflicts");
if core_conflicts.is_empty() {
return Err(missing_opening_cg_field_error(
"核心冲突缺失,无法生成开局 CG。",
));
}
let player_role = object
.get("playableNpcs")
.and_then(Value::as_array)
.and_then(|roles| roles.first())
.and_then(Value::as_object)
.ok_or_else(|| missing_opening_cg_field_error("缺少玩家扮演角色。"))?;
let player_role_image_src = read_string_field(player_role, "imageSrc").ok_or_else(|| {
missing_opening_cg_field_error("玩家扮演角色缺少角色参考图。")
})?;
let player_role_brief = build_opening_cg_player_role_brief(player_role);
let opening_scene_image_src = profile
.pointer("/sceneChapterBlueprints/0/acts/0/backgroundImageSrc")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
missing_opening_cg_field_error("首个场景第一幕背景图缺失,无法生成开局 CG。")
})?;
let opening_cg_id = format!("opening-cg-{}", current_utc_millis());
let storyboard_prompt = build_opening_cg_storyboard_prompt(
world_tone.as_str(),
player_role_brief.as_str(),
world_summary.as_str(),
core_conflicts.as_slice(),
);
Ok(NormalizedOpeningCgRequest {
profile_id,
world_name,
opening_cg_id,
storyboard_prompt,
video_prompt: OPENING_CG_VIDEO_PROMPT.to_string(),
player_role_image_src,
opening_scene_image_src,
})
}
fn missing_opening_cg_field_error(message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-opening-cg",
"message": message,
}))
}
fn build_opening_cg_storyboard_prompt(
world_tone: &str,
player_role_brief: &str,
world_summary: &str,
core_conflicts: &[String],
) -> String {
format!(
"以3*4网格格式创建故事板169。像素风角色扮演游戏开场动画CG。\n\n故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景\n故事基调:{}\n\n玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介:{}\n故事背景:{}\n核心冲突:{}\n开局场景:将首个场景的第一幕背景图作为参考图",
clamp_opening_cg_prompt_text(world_tone, 160),
clamp_opening_cg_prompt_text(player_role_brief, 320),
clamp_opening_cg_prompt_text(world_summary, 420),
clamp_opening_cg_prompt_text(core_conflicts.join("").as_str(), 360),
)
}
fn build_opening_cg_player_role_brief(role: &Map<String, Value>) -> String {
[
read_string_field(role, "name")
.map(|value| format!("姓名:{value}"))
.unwrap_or_default(),
read_string_field(role, "role")
.map(|value| format!("身份:{value}"))
.unwrap_or_default(),
read_string_field(role, "description")
.map(|value| format!("简介:{value}"))
.unwrap_or_default(),
read_string_field(role, "visualDescription")
.map(|value| format!("形象:{value}"))
.unwrap_or_default(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("")
}
fn read_string_array_field(object: &Map<String, Value>, key: &str) -> Vec<String> {
object
.get(key)
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn clamp_opening_cg_prompt_text(value: &str, max_length: usize) -> String {
clamp_text(value, max_length, false)
}
fn require_ark_video_settings(state: &AppState) -> Result<ArkVideoSettings, AppError> {
let base_url = state
.config
.ark_character_video_base_url
.trim()
.trim_end_matches('/');
if base_url.is_empty() {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "ark",
"reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置",
})));
}
let api_key = state
.config
.ark_character_video_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "ark",
"reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置",
}))
})?;
Ok(ArkVideoSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state
.config
.ark_character_video_request_timeout_ms
.max(OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS),
model: state.config.ark_character_video_model.clone(),
})
}
fn build_upstream_http_client(timeout_ms: u64) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-opening-cg",
"message": format!("构造上游 HTTP 客户端失败:{error}"),
}))
})
}
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前。
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
@@ -2143,6 +2846,20 @@ fn parse_json_payload(
})
}
fn parse_ark_video_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
@@ -2193,6 +2910,13 @@ fn map_dashscope_request_error(message: String) -> AppError {
}))
}
fn map_ark_video_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": message,
}))
}
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
@@ -2200,6 +2924,13 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr
}))
}
fn parse_ark_video_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": parse_api_error_message(raw_text, fallback_message),
}))
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
@@ -2236,6 +2967,61 @@ fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
}
fn extract_ark_task_id(payload: &Value) -> Option<String> {
payload
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| find_first_string_by_key(payload, "task_id"))
.or_else(|| find_first_string_by_key(payload, "taskId"))
.or_else(|| find_first_string_by_key(payload, "id"))
}
fn extract_video_url(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "video_url")
.or_else(|| find_first_string_by_key(payload, "videoUrl"))
.or_else(|| find_first_string_by_key(payload, "url"))
}
fn extract_generation_task_status(payload: &Value) -> String {
payload
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| find_first_string_by_key(payload, "task_status"))
.or_else(|| find_first_string_by_key(payload, "status"))
.unwrap_or_default()
}
fn normalize_generation_task_status(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(' ', "_")
}
fn is_completed_generation_task_status(status: &str) -> bool {
matches!(
status,
"completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed"
)
}
fn is_failed_generation_task_status(status: &str) -> bool {
matches!(
status,
"failed"
| "canceled"
| "cancelled"
| "error"
| "aborted"
| "rejected"
| "expired"
| "unknown"
)
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "image", &mut urls);
@@ -2263,6 +3049,20 @@ fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
}
}
fn normalize_downloaded_video_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("video/mp4");
match mime_type {
"video/mp4" | "video/quicktime" | "video/webm" | "video/x-msvideo" => {
mime_type.to_string()
}
_ => "video/mp4".to_string(),
}
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
@@ -2272,6 +3072,15 @@ fn mime_to_extension(mime_type: &str) -> &str {
}
}
fn video_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"video/quicktime" => "mov",
"video/webm" => "webm",
"video/x-msvideo" => "avi",
_ => "mp4",
}
}
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
if value.is_empty() {
String::new()
@@ -2391,6 +3200,12 @@ fn current_utc_micros() -> i64 {
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn current_utc_iso_text() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| format!("{}.000000Z", current_utc_millis()))
}
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}

View File

@@ -58,15 +58,12 @@ mod tests {
#[test]
fn form_seed_prompt_keeps_only_user_visible_fields() {
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: Some(" 暖灯猫街 "),
work_description: Some("雨夜礼物拼图"),
title: None,
work_description: None,
picture_description: Some("猫咪在灯牌下回头"),
});
assert_eq!(
prompt,
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
);
assert_eq!(prompt, "画面描述:猫咪在灯牌下回头");
}
#[test]

View File

@@ -0,0 +1,35 @@
/// 拼图首关关卡名生成提示词。
///
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
你会收到拼图第一关的画面描述。请生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
硬约束:
1. 只输出 JSON不要输出 Markdown、解释或代码块。
2. JSON 格式必须是 {"levelName":"关卡名"}。
3. levelName 必须是 2 到 8 个中文字符为主。
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
5. 不要输出标点、引号、编号、英文、emoji 或空白。
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
"#;
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
format!(
"画面描述:{picture_description}\n\n请生成第一关关卡名。",
picture_description = picture_description.trim(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_name_prompt_contains_picture_description() {
let prompt = build_puzzle_first_level_name_user_prompt("一只猫在雨夜灯牌下回头。");
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
assert!(prompt.contains("第一关关卡名"));
}
}

View File

@@ -1,3 +1,5 @@
pub(crate) mod agent_chat;
pub(crate) mod draft;
pub(crate) mod image;
pub(crate) mod level_name;
pub(crate) mod tags;

View File

@@ -0,0 +1,40 @@
/// 拼图作品标签生成提示词。
///
/// 这里只负责标签生成的文本契约,业务路由负责调用 LLM、解析结果和写回草稿。
pub(crate) const PUZZLE_TAG_GENERATION_SYSTEM_PROMPT: &str = r#"你是一个中文内容标签编辑。
你会收到拼图作品名称和作品描述。请生成 6 个适合作品广场检索和相似推荐的中文短标签。
硬约束:
1. 只输出 JSON不要输出 Markdown、解释或代码块。
2. JSON 格式必须是 {"tags":["标签1","标签2","标签3","标签4","标签5","标签6"]}。
3. tags 必须正好 6 个。
4. 每个标签 2 到 6 个中文字符为主,不要整句描述。
5. 不要输出空标签、重复标签、英文标签、编号、标点或井号。
6. 标签要覆盖题材、主体、氛围、场景、风格和拼图辨识点。
"#;
pub(crate) fn build_puzzle_tag_generation_user_prompt(
work_title: &str,
work_description: &str,
) -> String {
format!(
"作品名称:{work_title}\n作品描述:{work_description}\n\n请生成 6 个作品标签。",
work_title = work_title.trim(),
work_description = work_description.trim(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_prompt_contains_title_and_description() {
let prompt = build_puzzle_tag_generation_user_prompt("雨夜猫街", "一套暖灯街角主题拼图。");
assert!(prompt.contains("作品名称:雨夜猫街"));
assert!(prompt.contains("作品描述:一套暖灯街角主题拼图。"));
assert!(prompt.contains("6 个作品标签"));
}
}

View File

@@ -18,6 +18,7 @@ use module_assets::{
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -76,6 +77,7 @@ use crate::{
},
auth::AuthenticatedAccessToken,
http_error::AppError,
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
platform_errors::map_oss_error,
prompt::puzzle::{
draft::{
@@ -83,6 +85,10 @@ use crate::{
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
},
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
level_name::{
PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt,
},
tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt},
},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
@@ -527,15 +533,15 @@ pub async fn execute_puzzle_agent_action(
});
(
"compile_puzzle_draft",
"完整拼图草稿",
"已编译草稿、生成拼图图片并应用为正式图",
"首关拼图草稿",
"已编译首关草稿、生成首关画面并写入正式草稿",
session,
)
}
"save_puzzle_form_draft" => {
let seed_text = build_puzzle_form_seed_text_from_parts(
payload.work_title.as_deref(),
payload.work_description.as_deref(),
None,
None,
payload
.picture_description
.as_deref()
@@ -705,6 +711,66 @@ pub async fn execute_puzzle_agent_action(
session,
)
}
"generate_puzzle_tags" => {
let work_title = payload
.work_title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
puzzle_bad_request(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
"作品名称不能为空",
)
})?;
let work_description = payload
.work_description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
puzzle_bad_request(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
"作品描述不能为空",
)
})?;
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
})),
)
})?;
let generated_tags =
generate_puzzle_work_tags(&state, work_title, work_description).await;
let session = save_generated_puzzle_tags_to_session(
&state,
&session_id,
&owner_user_id,
&payload,
generated_tags,
levels_json,
now,
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_tags",
"作品标签生成",
"已生成 6 个作品标签。",
session,
)
}
"select_puzzle_image" => {
let candidate_id = payload
.candidate_id
@@ -2058,12 +2124,12 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: payload
.work_title
title: None,
work_description: None,
picture_description: payload
.picture_description
.as_deref()
.or(payload.seed_text.as_deref()),
work_description: payload.work_description.as_deref(),
picture_description: payload.picture_description.as_deref(),
})
}
@@ -2088,8 +2154,8 @@ async fn save_puzzle_form_payload_before_compile(
now: i64,
) -> Result<String, Response> {
let seed_text = build_puzzle_form_seed_text_from_parts(
payload.work_title.as_deref(),
payload.work_description.as_deref(),
None,
None,
payload
.picture_description
.as_deref()
@@ -2486,6 +2552,176 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
)
}
async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String {
if let Some(llm_client) = state.llm_client() {
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
if let Some(level_name) =
parse_puzzle_first_level_name_from_text(response.content.as_str())
{
return level_name;
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
picture_chars = picture_description.chars().count(),
"拼图首关名模型返回非法,降级使用关键词名"
);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
picture_chars = picture_description.chars().count(),
error = %error,
"拼图首关名生成失败,降级使用关键词名"
);
}
}
}
build_fallback_puzzle_first_level_name(picture_description)
}
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
let trimmed = text.trim();
let json_text = if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
&trimmed[start..=end]
} else {
trimmed
};
let parsed = serde_json::from_str::<Value>(json_text).ok();
let raw_name = parsed
.as_ref()
.and_then(|value| value.get("levelName").and_then(Value::as_str))
.or_else(|| {
parsed
.as_ref()
.and_then(|value| value.get("level_name").and_then(Value::as_str))
})
.unwrap_or(trimmed);
normalize_puzzle_first_level_name(raw_name)
}
fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
let normalized = value
.trim()
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ''))
.chars()
.filter(|ch| {
!matches!(
ch,
'#' | '"'
| '\''
| '`'
| ' '
| '\t'
| '\r'
| '\n'
| ''
| '。'
| '、'
| ''
| ''
| ''
| ''
| '“'
| '”'
| '《'
| '》'
)
})
.take(12)
.collect::<String>();
let normalized = strip_puzzle_level_name_generic_words(normalized);
if normalized.chars().count() >= 2
&& !matches!(
normalized.as_str(),
"第一关" | "画面" | "拼图" | "作品" | "关卡"
)
{
Some(normalized)
} else {
None
}
}
fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
for prefix in ["第一关", "关卡名", "关卡"] {
value = value.trim_start_matches(prefix).to_string();
}
for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] {
value = value.trim_end_matches(suffix).to_string();
}
value.chars().take(8).collect()
}
fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String {
let source = picture_description.trim();
if source.contains("") && (source.contains("雨夜") || source.contains('雨')) {
return "雨夜猫街".to_string();
}
if source.contains("") && source.contains('灯') {
return "暖灯猫街".to_string();
}
for (keyword, level_name) in [
("雨夜", "雨夜灯街"),
("", "暖灯猫街"),
("", "花园小狗"),
("神庙", "神庙遗光"),
("遗迹", "遗迹谜光"),
("森林", "森林秘境"),
("城市", "霓虹城市"),
("机械", "机械迷城"),
("蒸汽", "蒸汽街区"),
("", "海岸微光"),
("", "花园晨光"),
("", "雪境小径"),
("", "龙影高塔"),
("", "暖灯街角"),
("", "塔顶星光"),
] {
if source.contains(keyword) {
return level_name.to_string();
}
}
"奇境初见".to_string()
}
fn build_puzzle_levels_with_primary_name(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> Vec<PuzzleDraftLevelRecord> {
let mut levels = draft.levels.clone();
if let Some(index) = levels
.iter()
.position(|level| level.level_id == target_level.level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
{
levels[index].level_name = target_level.level_name.clone();
}
levels
}
async fn compile_puzzle_draft_with_initial_cover(
state: &AppState,
session_id: String,
@@ -2506,7 +2742,14 @@ async fn compile_puzzle_draft_with_initial_cover(
"message": "拼图结果页草稿尚未生成",
}))
})?;
let target_level = select_puzzle_level_for_api(&draft, None)?;
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
let fallback_level_name = target_level.level_name.clone();
let generated_level_name =
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
target_level.level_name = generated_level_name.clone();
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_name(&draft, &target_level),
)?);
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
@@ -2554,7 +2797,7 @@ async fn compile_puzzle_draft_with_initial_cover(
session_id: compiled_session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: None,
levels_json: levels_json_with_generated_name,
candidates_json,
saved_at_micros: current_utc_micros(),
})
@@ -2572,7 +2815,13 @@ async fn compile_puzzle_draft_with_initial_cover(
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let session = apply_generated_puzzle_candidates_to_session_snapshot(
compiled_session.clone(),
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
),
target_level.level_id.as_str(),
candidates.clone(),
now,
@@ -2655,6 +2904,39 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
session
}
fn apply_generated_puzzle_first_level_name_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
level_name: &str,
previous_level_name: &str,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
let normalized_name = level_name.trim();
if normalized_name.is_empty() {
return session;
}
let Some(target_index) = draft
.levels
.iter()
.position(|level| level.level_id == target_level_id)
.or_else(|| (!draft.levels.is_empty()).then_some(0))
else {
return session;
};
draft.levels[target_index].level_name = normalized_name.to_string();
let should_default_work_title =
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
if target_index == 0 && should_default_work_title {
draft.work_title = normalized_name.to_string();
}
sync_puzzle_primary_draft_fields_from_level(draft);
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
let Some(primary_level) = draft.levels.first() else {
return;
@@ -2677,6 +2959,305 @@ fn replace_puzzle_session_draft_snapshot(
session
}
async fn generate_puzzle_work_tags(
state: &AppState,
work_title: &str,
work_description: &str,
) -> Vec<String> {
if let Some(llm_client) = state.llm_client() {
let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text(
response.content.as_str(),
));
if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
return tags;
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
work_title,
"拼图 AI 标签数量不足,降级使用关键词补齐"
);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
work_title,
error = %error,
"拼图 AI 标签生成失败,降级使用关键词标签"
);
}
}
}
normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description))
}
fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
let trimmed = text.trim();
let json_text = if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
&trimmed[start..=end]
} else {
trimmed
};
let Ok(value) = serde_json::from_str::<Value>(json_text) else {
return normalize_puzzle_tag_candidates(trimmed.split([',', '', '、', '\n']));
};
let Some(tags) = value.get("tags").and_then(Value::as_array) else {
return Vec::new();
};
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
}
fn normalize_puzzle_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
where
S: AsRef<str>,
{
let mut tags = Vec::new();
for candidate in candidates {
let normalized = normalize_puzzle_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
}
tags.push(normalized);
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
break;
}
}
for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] {
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
fn normalize_puzzle_tag(value: &str) -> String {
value
.trim()
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ''))
.trim()
.chars()
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
.take(6)
.collect::<String>()
}
fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
let source = format!("{work_title} {work_description}");
let mut tags = Vec::new();
for (keyword, tag) in [
("", "猫咪"),
("", "小狗"),
("神庙", "神庙遗迹"),
("遗迹", "神庙遗迹"),
("森林", "童话森林"),
("", "雨夜"),
("", "夜景"),
("城市", "城市奇景"),
("蒸汽", "蒸汽城市"),
("机械", "机械幻想"),
("", "海岸"),
("", "花园"),
("", "雪景"),
("", "幻想生物"),
("", "暖灯"),
("", "高塔"),
] {
if source.contains(keyword) && !tags.contains(&tag) {
tags.push(tag);
}
}
tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]);
tags
}
async fn save_generated_puzzle_tags_to_session(
state: &AppState,
session_id: &str,
owner_user_id: &str,
payload: &ExecutePuzzleAgentActionRequest,
generated_tags: Vec<String>,
levels_json: Option<String>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_puzzle_client_error)?;
let draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
let mut levels = if let Some(levels_json) = levels_json.as_deref() {
parse_puzzle_level_records_from_module_json(levels_json)?
} else {
draft.levels.clone()
};
if levels.is_empty() {
levels = draft.levels.clone();
}
let first_level = levels.first().cloned().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图草稿缺少可编辑关卡",
}))
})?;
let work_title = payload
.work_title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(draft.work_title.as_str())
.to_string();
let work_description = payload
.work_description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(draft.work_description.as_str())
.to_string();
let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?);
let (_, profile_id) = build_stable_puzzle_work_ids(session_id);
state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
profile_id,
owner_user_id: owner_user_id.to_string(),
work_title: work_title.clone(),
work_description: work_description.clone(),
level_name: first_level.level_name.clone(),
summary: work_description.clone(),
theme_tags: generated_tags.clone(),
cover_image_src: first_level.cover_image_src.clone(),
cover_asset_id: first_level.cover_asset_id.clone(),
levels_json,
updated_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)?;
Ok(apply_generated_puzzle_tags_to_session_snapshot(
session,
generated_tags,
work_title,
work_description,
levels,
now,
))
}
fn apply_generated_puzzle_tags_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
generated_tags: Vec<String>,
work_title: String,
work_description: String,
levels: Vec<PuzzleDraftLevelRecord>,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
draft.work_title = work_title;
draft.work_description = work_description.clone();
draft.summary = work_description;
draft.theme_tags = generated_tags;
draft.levels = levels;
sync_puzzle_primary_draft_fields_from_level(draft);
session.progress_percent = session.progress_percent.max(96);
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
"ready_to_publish".to_string()
} else {
"image_refining".to_string()
};
session.last_assistant_reply = Some("作品标签已生成。".to_string());
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
!draft.work_title.trim().is_empty()
&& !draft.work_description.trim().is_empty()
&& draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT
&& draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT
&& !draft.levels.is_empty()
&& draft.levels.iter().all(|level| {
!level.level_name.trim().is_empty()
&& level
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
})
}
fn serialize_puzzle_level_records_for_module(
levels: &[PuzzleDraftLevelRecord],
) -> Result<String, AppError> {
let payload = levels
.iter()
.map(|level| {
json!({
"level_id": level.level_id,
"level_name": level.level_name,
"picture_description": level.picture_description,
"candidates": level
.candidates
.iter()
.map(|candidate| {
json!({
"candidate_id": candidate.candidate_id,
"image_src": candidate.image_src,
"asset_id": candidate.asset_id,
"prompt": candidate.prompt,
"actual_prompt": candidate.actual_prompt,
"source_type": candidate.source_type,
"selected": candidate.selected,
})
})
.collect::<Vec<_>>(),
"selected_candidate_id": level.selected_candidate_id,
"cover_image_src": level.cover_image_src,
"cover_asset_id": level.cover_asset_id,
"generation_status": level.generation_status,
})
})
.collect::<Vec<_>>();
serde_json::to_string(&payload).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图关卡列表序列化失败:{error}"),
}))
})
}
fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
matches!(
error.status_code(),
@@ -3069,6 +3650,84 @@ mod tests {
);
}
#[test]
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
Some("雨夜猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
Some("暖灯猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
Some("雨夜猫街".to_string())
);
}
#[test]
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
assert_eq!(
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
"雨夜猫街"
);
assert_eq!(
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
"奇境初见"
);
}
#[test]
fn puzzle_first_level_name_snapshot_defaults_work_title() {
let levels_json = serde_json::to_string(&vec![json!({
"level_id": "puzzle-level-1",
"level_name": "猫画面",
"picture_description": "一只猫在雨夜灯牌下回头。",
"candidates": [],
"selected_candidate_id": null,
"cover_image_src": null,
"cover_asset_id": null,
"generation_status": "idle",
})])
.expect("levels json");
let payload = ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
candidate_count: Some(1),
candidate_id: None,
level_id: Some("puzzle-level-1".to_string()),
work_title: Some("猫画面".to_string()),
work_description: None,
picture_description: None,
level_name: None,
summary: None,
theme_tags: Some(vec![]),
levels_json: Some(levels_json.clone()),
};
let session = build_puzzle_session_snapshot_from_action_payload(
"puzzle-session-1",
&payload,
Some(levels_json.as_str()),
1_713_686_401_234_567,
)
.expect("fallback session");
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
session,
"puzzle-level-1",
"雨夜猫街",
"猫画面",
1_713_686_401_234_568,
);
let draft = renamed.draft.expect("draft");
assert_eq!(draft.level_name, "雨夜猫街");
assert_eq!(draft.work_title, "雨夜猫街");
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
}
#[test]
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
let invalid_operation =

View File

@@ -115,7 +115,7 @@ pub async fn begin_story_runtime_session(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
persisted.version,
None,
),
},
))
@@ -257,7 +257,7 @@ pub async fn resolve_story_runtime_action(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
resolved.server_version.max(persisted.version),
Some(resolved.server_version),
),
},
))
@@ -395,7 +395,7 @@ fn build_story_runtime_projection_from_persisted(
story_session: StorySessionPayload,
story_events: Vec<StoryEventPayload>,
record: &RuntimeSnapshotRecord,
server_version: u32,
resolved_version: Option<u32>,
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
let snapshot = story_runtime_snapshot_payload_from_record(record);
let current_story = snapshot.current_story.as_ref();
@@ -405,6 +405,8 @@ fn build_story_runtime_projection_from_persisted(
.or_else(|| Some(story_session.latest_narrative_text.clone()));
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
let toast = read_story_runtime_current_field(current_story, "toast");
let server_version =
resolve_story_runtime_projection_version(&snapshot.game_state, resolved_version);
module_runtime_story::build_story_runtime_projection(
module_runtime_story::StoryRuntimeProjectionSource {
@@ -420,6 +422,15 @@ fn build_story_runtime_projection_from_persisted(
)
}
fn resolve_story_runtime_projection_version(
game_state: &Value,
resolved_version: Option<u32>,
) -> u32 {
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
.or(resolved_version)
.unwrap_or(1)
}
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
read_story_runtime_current_field(current_story, "text")
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
@@ -619,10 +630,12 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use super::require_story_session_owner;
use super::{build_story_runtime_projection_from_persisted, require_story_session_owner};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
use module_runtime::RuntimeSnapshotRecord;
use shared_contracts::story::StorySessionPayload;
#[tokio::test]
async fn begin_story_session_requires_authentication() {
@@ -1028,6 +1041,56 @@ mod tests {
);
}
#[test]
fn story_runtime_projection_version_prefers_runtime_action_version() {
let projection = build_story_runtime_projection_from_persisted(
StorySessionPayload {
story_session_id: "storysess_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_1".to_string(),
world_profile_id: "profile_1".to_string(),
initial_prompt: "进入营地".to_string(),
opening_summary: Some("营地开场".to_string()),
latest_narrative_text: "最新故事".to_string(),
latest_choice_function_id: Some("npc_chat".to_string()),
status: "active".to_string(),
version: 9,
created_at: "1.000000Z".to_string(),
updated_at: "3.000000Z".to_string(),
},
vec![],
&RuntimeSnapshotRecord {
user_id: "user_1".to_string(),
version: 2,
saved_at: "3.000000Z".to_string(),
saved_at_micros: 3,
bottom_tab: "adventure".to_string(),
game_state: json!({
"runtimeSessionId": "runtime_001",
"runtimeActionVersion": 7,
"playerHp": 30,
"playerMaxHp": 40,
"playerMana": 10,
"playerMaxMana": 20,
"playerCurrency": 0,
"playerInventory": [],
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
"inBattle": false,
"npcInteractionActive": false,
"storyHistory": []
}),
current_story: None,
game_state_json: "{}".to_string(),
current_story_json: None,
created_at_micros: 1,
updated_at_micros: 3,
},
None,
);
assert_eq!(projection.server_version, 7);
}
#[test]
fn story_session_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(

View File

@@ -125,10 +125,7 @@ pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleA
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(
normalized_title.as_deref().unwrap_or(""),
normalized_description.as_deref().unwrap_or(""),
);
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(title, picture_description);
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
pack
@@ -178,12 +175,12 @@ pub fn compile_result_draft_from_seed(
seed_text: Option<&str>,
) -> PuzzleResultDraft {
let creator_intent = build_creator_intent(anchor_pack, messages);
let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone());
let work_title = build_work_title(anchor_pack);
let normalized_tags = resolve_initial_theme_tags(seed_text, &creator_intent);
let work_description = resolve_work_description(seed_text, anchor_pack);
let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体");
let level_name =
build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1);
let work_title = resolve_work_title(seed_text, anchor_pack, &level_name);
let level = PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: level_name.clone(),
@@ -238,16 +235,6 @@ pub fn build_form_draft_from_parts(
let work_description = work_description.and_then(|value| normalize_required_string(&value));
let picture_description =
picture_description.and_then(|value| normalize_required_string(&value));
let title_for_tags = work_title.as_deref().unwrap_or("");
let picture_for_tags = picture_description.as_deref().unwrap_or("");
let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags));
if tags.is_empty() {
tags = vec![
"拼图".to_string(),
"插画".to_string(),
"清晰构图".to_string(),
];
}
let summary = work_description.clone().unwrap_or_default();
let level = PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
@@ -266,7 +253,7 @@ pub fn build_form_draft_from_parts(
work_description: summary.clone(),
level_name: String::new(),
summary,
theme_tags: tags,
theme_tags: Vec::new(),
forbidden_directives: Vec::new(),
creator_intent: None,
anchor_pack: anchor_pack.clone(),
@@ -349,12 +336,6 @@ pub fn apply_selected_candidate(
}
pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft {
if draft.work_title.trim().is_empty() {
draft.work_title = fallback_text(&draft.anchor_pack.theme_promise.value, &draft.level_name);
}
if draft.work_description.trim().is_empty() {
draft.work_description = draft.summary.clone();
}
if draft.levels.is_empty() {
draft.levels = vec![PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
@@ -383,9 +364,6 @@ pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) {
draft.cover_asset_id = primary_level.cover_asset_id.clone();
draft.generation_status = primary_level.generation_status.clone();
}
if draft.work_description.trim().is_empty() {
draft.work_description = draft.summary.clone();
}
draft.summary = draft.work_description.clone();
if draft.form_draft.is_some() {
draft.form_draft = Some(PuzzleFormDraft {
@@ -642,23 +620,19 @@ pub fn apply_publish_overrides_to_draft(
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
let mut next_draft = normalize_puzzle_draft(draft.clone());
if let Some(next_work_title) = work_title
&& let Some(normalized_work_title) = normalize_required_string(&next_work_title)
{
next_draft.work_title = normalized_work_title;
if let Some(next_work_title) = work_title {
next_draft.work_title = normalize_required_string(&next_work_title).unwrap_or_default();
}
if let Some(next_work_description) = work_description
&& let Some(normalized_work_description) = normalize_required_string(&next_work_description)
{
next_draft.work_description = normalized_work_description;
if let Some(next_work_description) = work_description {
next_draft.work_description =
normalize_required_string(&next_work_description).unwrap_or_default();
}
if let Some(next_level_name) = level_name
&& let Some(normalized_level_name) = normalize_required_string(&next_level_name)
{
if let Some(next_level_name) = level_name {
if let Some(primary_level) = next_draft.levels.first_mut() {
primary_level.level_name = normalized_level_name;
primary_level.level_name =
normalize_required_string(&next_level_name).unwrap_or_default();
}
}
@@ -689,7 +663,7 @@ pub fn apply_publish_overrides_to_draft(
pub fn normalize_puzzle_levels(
levels: Vec<PuzzleDraftLevel>,
theme_tags: &[String],
_theme_tags: &[String],
) -> Result<Vec<PuzzleDraftLevel>, PuzzleFieldError> {
let mut normalized_levels = Vec::new();
for (index, mut level) in levels.into_iter().enumerate() {
@@ -697,9 +671,7 @@ pub fn normalize_puzzle_levels(
.unwrap_or_else(|| format!("puzzle-level-{}", index + 1));
let picture_description = normalize_required_string(&level.picture_description)
.unwrap_or_else(|| format!("{}关画面", index + 1));
let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| {
build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1)
});
let level_name = normalize_required_string(&level.level_name).unwrap_or_default();
level.level_id = level_id;
level.level_name = level_name;
level.picture_description = picture_description;
@@ -1959,21 +1931,67 @@ fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String {
}
fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String {
seed_text
.and_then(parse_form_seed_text)
.and_then(|parts| {
parts
.work_description
.or(parts.picture_description)
.or(parts.work_title)
})
.unwrap_or_else(|| build_result_summary(anchor_pack))
if let Some(parts) = seed_text.and_then(parse_form_seed_text) {
if parts.picture_description.is_some()
&& parts.work_title.is_none()
&& parts.work_description.is_none()
{
return String::new();
}
return parts
.work_description
.unwrap_or_else(|| build_result_summary(anchor_pack));
}
build_result_summary(anchor_pack)
}
fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String {
fallback_text(&anchor_pack.theme_promise.value, "奇景拼图")
}
fn resolve_work_title(
seed_text: Option<&str>,
anchor_pack: &PuzzleAnchorPack,
level_name: &str,
) -> String {
seed_text
.and_then(parse_form_seed_text)
.and_then(|parts| {
parts
.work_title
.or_else(|| normalize_required_string(level_name))
})
.unwrap_or_else(|| build_work_title(anchor_pack))
}
fn resolve_initial_theme_tags(
seed_text: Option<&str>,
creator_intent: &PuzzleCreatorIntent,
) -> Vec<String> {
if let Some(parts) = seed_text.and_then(parse_form_seed_text) {
if parts.picture_description.is_some()
&& parts.work_title.is_none()
&& parts.work_description.is_none()
{
return Vec::new();
}
let derived_tags = normalize_theme_tags(derive_form_theme_tags(
parts
.work_title
.as_deref()
.unwrap_or(creator_intent.theme_promise.as_str()),
parts
.picture_description
.as_deref()
.unwrap_or(creator_intent.visual_subject.as_str()),
));
if !derived_tags.is_empty() {
return derived_tags;
}
}
normalize_theme_tags(creator_intent.theme_tags.clone())
}
fn extract_forbidden_directive(source: &str) -> String {
if let Some((_, tail)) = source.split_once('') {
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
@@ -1996,7 +2014,7 @@ fn build_level_name_from_picture(
}
}
if let Some(tag) = normalized_tags.first() {
return format!("{tag}{level_index}");
return format!("{tag}画面");
}
format!("{level_index}")
}
@@ -2912,6 +2930,23 @@ mod tests {
assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT);
}
#[test]
fn picture_only_form_seed_uses_level_name_as_work_title_and_empty_metadata() {
let seed_text = "画面描述:一只猫在雨夜灯牌下回头。";
let anchor_pack = infer_anchor_pack(seed_text, None);
let draft = compile_result_draft_from_seed(&anchor_pack, &[], Some(seed_text));
assert_eq!(draft.level_name, "猫画面");
assert_eq!(draft.work_title, "猫画面");
assert_eq!(draft.work_description, "");
assert_eq!(draft.summary, "");
assert!(draft.theme_tags.is_empty());
assert_eq!(
draft.levels[0].picture_description,
"一只猫在雨夜灯牌下回头。"
);
}
#[test]
fn form_seed_keeps_multiline_picture_description() {
let anchor_pack = infer_anchor_pack(
@@ -3452,4 +3487,34 @@ mod tests {
assert_eq!(error, PuzzleFieldError::InvalidTagCount);
}
#[test]
fn apply_publish_overrides_preserves_empty_level_name_for_publish_gate() {
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
let draft = compile_result_draft(&anchor_pack, &[]);
let mut levels = draft.levels.clone();
levels[0].level_name = " ".to_string();
let updated = apply_publish_overrides_to_draft(
&draft,
Some("雨夜猫塔作品".to_string()),
Some("作品描述。".to_string()),
Some("".to_string()),
Some("作品描述。".to_string()),
Some(vec![
"雨夜".to_string(),
"猫咪".to_string(),
"遗迹".to_string(),
]),
Some(levels),
)
.expect("empty level name should remain editable before publish gate");
assert_eq!(updated.levels[0].level_name, "");
assert!(
validate_publish_requirements(&updated, Some("玩家"))
.iter()
.any(|blocker| blocker.code == "MISSING_LEVEL_NAME")
);
}
}

View File

@@ -29,6 +29,7 @@
4. 生成绑定到 BFF record / module record 的 row snapshot mapper 已集中在 `mapper.rs`
5. SDK 调用错误、reducer 业务错误、procedure 业务错误、缺快照错误和本地输入校验错误已统一收口到 `SpacetimeClientError` helper。
6. Story runtime projection source 已复用 runtime inventory typed facade读取投影不再只依赖 runtime snapshot 中的历史背包 JSON 副本。
7. Story runtime 投影读取会对历史 `currentStory.options` 做兼容推断:若旧快照缺少 `scope`,仍会按 `functionId` 通过 `module-runtime-story` 的 option helper 还原为 `story / combat / npc` 作用域,避免旧存档把读取链路卡死。
`confirm_asset_object_and_return``bind_asset_object_to_entity_and_return` 的调用必须等到 SDK `on_connect` 回调后再发起。`DbConnection::build()` 只代表 WebSocket 已经初始化,不代表 SpacetimeDB 身份握手完成;如果过早调用 procedure本地联调会表现为连接建立但请求长期没有回调最终等到 idle timeout。

View File

@@ -1,10 +1,7 @@
use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord};
use module_runtime_story::StoryRuntimeProjectionSource;
use serde_json::{Map, Value, json};
use shared_contracts::{
runtime_story::RuntimeStoryOptionView,
story::{StoryEventPayload, StorySessionPayload},
};
use shared_contracts::story::{StoryEventPayload, StorySessionPayload};
use std::collections::HashMap;
use super::*;
@@ -43,7 +40,10 @@ impl SpacetimeClient {
)?;
let current_story = runtime_snapshot.current_story.as_ref();
let latest_narrative_text = story_state.session.latest_narrative_text.clone();
let server_version = runtime_snapshot.version.max(story_state.session.version);
let server_version =
resolve_story_runtime_server_version(&game_state, story_state.session.version);
let options = module_runtime_story::build_runtime_story_options(current_story, &game_state);
Ok(StoryRuntimeProjectionSource {
story_session: build_story_session_payload(story_state.session),
@@ -53,7 +53,7 @@ impl SpacetimeClient {
.map(build_story_event_payload)
.collect(),
game_state,
options: read_runtime_story_options(current_story)?,
options,
server_version,
current_narrative_text: read_current_story_text(current_story)
.or(Some(latest_narrative_text)),
@@ -311,20 +311,6 @@ fn build_story_event_payload(record: StoryEventRecord) -> StoryEventPayload {
}
}
fn read_runtime_story_options(
current_story: Option<&Value>,
) -> Result<Vec<RuntimeStoryOptionView>, SpacetimeClientError> {
let Some(options) = current_story.and_then(|story| story.get("options")) else {
return Ok(Vec::new());
};
serde_json::from_value::<Vec<RuntimeStoryOptionView>>(options.clone()).map_err(|error| {
SpacetimeClientError::Runtime(format!(
"currentStory.options 无法映射为后端选项投影: {error}"
))
})
}
fn read_current_story_text(current_story: Option<&Value>) -> Option<String> {
read_current_story_string(current_story, "text")
.or_else(|| read_current_story_string(current_story, "storyText"))
@@ -340,6 +326,20 @@ fn read_current_story_string(current_story: Option<&Value>, field: &str) -> Opti
.map(ToOwned::to_owned)
}
fn read_current_runtime_action_version(game_state: &Value) -> Option<u32> {
game_state
.as_object()?
.get("runtimeActionVersion")?
.as_u64()
.and_then(|value| u32::try_from(value).ok())
}
fn resolve_story_runtime_server_version(game_state: &Value, story_session_version: u32) -> u32 {
read_current_runtime_action_version(game_state)
.or(Some(story_session_version))
.unwrap_or(1)
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -434,16 +434,26 @@ mod tests {
}
#[test]
fn current_story_options_parse_runtime_story_options() {
let options = read_runtime_story_options(Some(&json!({
fn runtime_projection_source_uses_runtime_action_version() {
let game_state = json!({
"runtimeSessionId": "runtime_1",
"runtimeActionVersion": 1
});
assert_eq!(resolve_story_runtime_server_version(&game_state, 3), 1);
}
#[test]
fn current_story_options_infer_scope_for_legacy_story_options() {
let current_story = json!({
"text": "守火人抬眼看着你。",
"options": [{
"functionId": "npc_chat",
"actionText": "继续交谈",
"scope": "npc"
"actionText": "继续交谈"
}]
})))
.expect("options should parse");
});
let options =
module_runtime_story::build_runtime_story_options(Some(&current_story), &json!({}));
assert_eq!(options[0].function_id, "npc_chat");
assert_eq!(options[0].action_text, "继续交谈");

View File

@@ -946,10 +946,18 @@ fn save_puzzle_generated_images_tx(
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
let previous_primary_level_name = draft.level_name.clone();
let previous_work_title = draft.work_title.clone();
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
draft.levels = levels;
module_puzzle::sync_primary_level_fields(&mut draft);
// 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。
sync_generated_primary_level_name_as_default_work_title(
&mut draft,
&previous_work_title,
&previous_primary_level_name,
);
}
let candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
@@ -1014,6 +1022,18 @@ fn save_puzzle_generated_images_tx(
)
}
fn sync_generated_primary_level_name_as_default_work_title(
draft: &mut PuzzleResultDraft,
previous_work_title: &str,
previous_primary_level_name: &str,
) {
if previous_work_title.trim().is_empty()
|| previous_work_title.trim() == previous_primary_level_name.trim()
{
draft.work_title = draft.level_name.clone();
}
}
fn select_puzzle_cover_image_tx(
ctx: &TxContext,
input: PuzzleSelectCoverImageInput,
@@ -1189,7 +1209,7 @@ fn update_puzzle_work_tx(
return Err("无权修改该拼图作品".to_string());
}
let theme_tags = normalize_theme_tags(input.theme_tags);
if theme_tags.is_empty() || theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
if theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
return Err("拼图标签数量不合法".to_string());
}
let levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
@@ -1251,6 +1271,7 @@ fn update_puzzle_work_tx(
published_at: row.published_at,
};
replace_puzzle_work_profile(ctx, &row, next_row);
sync_puzzle_source_session_draft_from_work(ctx, &row, &preview_draft, input.updated_at_micros)?;
get_puzzle_work_detail_tx(
ctx,
PuzzleWorkGetInput {
@@ -1259,6 +1280,53 @@ fn update_puzzle_work_tx(
)
}
fn sync_puzzle_source_session_draft_from_work(
ctx: &TxContext,
work_row: &PuzzleWorkProfileRow,
draft: &PuzzleResultDraft,
updated_at_micros: i64,
) -> Result<(), String> {
let Some(session_id) = work_row.source_session_id.as_ref() else {
return Ok(());
};
let Some(session_row) = ctx.db.puzzle_agent_session().session_id().find(session_id) else {
return Ok(());
};
if session_row.owner_user_id != work_row.owner_user_id {
return Ok(());
}
let normalized_draft = normalize_puzzle_draft(draft.clone());
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
let next_stage = if session_row.stage == PuzzleAgentStage::Published {
PuzzleAgentStage::Published
} else if build_result_preview(&normalized_draft, Some(&work_row.author_display_name))
.publish_ready
{
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
replace_puzzle_agent_session(
ctx,
&session_row,
PuzzleAgentSessionRow {
session_id: session_row.session_id.clone(),
owner_user_id: session_row.owner_user_id.clone(),
seed_text: session_row.seed_text.clone(),
current_turn: session_row.current_turn,
progress_percent: session_row.progress_percent.max(94),
stage: next_stage,
anchor_pack_json: session_row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&normalized_draft)),
last_assistant_reply: session_row.last_assistant_reply.clone(),
published_profile_id: session_row.published_profile_id.clone(),
created_at: session_row.created_at,
updated_at,
},
);
Ok(())
}
fn delete_puzzle_work_tx(
ctx: &TxContext,
input: PuzzleWorkDeleteInput,
@@ -3298,6 +3366,53 @@ mod tests {
assert!(draft.candidates[0].selected);
}
#[test]
fn generated_first_level_name_defaults_work_title_when_previous_title_is_fallback() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
let mut draft = compile_result_draft_from_seed(
&anchor_pack,
&[],
Some("画面描述:一只猫在雨夜灯牌下回头。"),
);
let previous_level_name = draft.level_name.clone();
let previous_work_title = draft.work_title.clone();
draft.levels[0].level_name = "雨夜猫街".to_string();
module_puzzle::sync_primary_level_fields(&mut draft);
sync_generated_primary_level_name_as_default_work_title(
&mut draft,
&previous_work_title,
&previous_level_name,
);
assert_eq!(draft.level_name, "雨夜猫街");
assert_eq!(draft.work_title, "雨夜猫街");
}
#[test]
fn generated_first_level_name_keeps_manual_work_title() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
let mut draft = compile_result_draft_from_seed(
&anchor_pack,
&[],
Some("画面描述:一只猫在雨夜灯牌下回头。"),
);
let previous_level_name = draft.level_name.clone();
let previous_work_title = "我的猫街合集".to_string();
draft.work_title = previous_work_title.clone();
draft.levels[0].level_name = "雨夜猫街".to_string();
module_puzzle::sync_primary_level_fields(&mut draft);
sync_generated_primary_level_name_as_default_work_title(
&mut draft,
&previous_work_title,
&previous_level_name,
);
assert_eq!(draft.level_name, "雨夜猫街");
assert_eq!(draft.work_title, "我的猫街合集");
}
#[test]
fn puzzle_recommendation_score_prefers_same_author_weight() {
let left = PuzzleWorkProfile {