refactor: modularize api server assets and handlers

This commit is contained in:
2026-05-14 22:54:52 +08:00
parent 4ba1ebbbdf
commit 1b54db4f92
47 changed files with 8081 additions and 6142 deletions

View File

@@ -0,0 +1,645 @@
use super::*;
struct BigFishDashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct BigFishGeneratedImage {
image_url: String,
task_id: String,
}
struct BigFishDownloadedImage {
mime_type: String,
bytes: Vec<u8>,
}
struct BigFishFormalAssetContext {
entity_id: String,
prompt: String,
negative_prompt: String,
size: String,
asset_object_kind: String,
binding_slot: String,
path_segments: Vec<String>,
apply_transparent_background_post_process: bool,
}
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
pub(super) async fn generate_big_fish_formal_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
asset_kind: &str,
level: Option<u32>,
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result<String, AppError> {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
}
fn build_big_fish_formal_asset_context(
session: &BigFishSessionRecord,
draft: &BigFishGameDraftRecord,
asset_kind: &str,
level: Option<u32>,
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result<BigFishFormalAssetContext, AppError> {
let asset_id = format!("asset-{generated_at_micros}");
match asset_kind {
"level_main_image" => {
let level = find_big_fish_level_blueprint(draft, level)?;
let level_part = build_big_fish_level_part(Some(level.level));
Ok(BigFishFormalAssetContext {
entity_id: session.session_id.clone(),
prompt: build_big_fish_level_main_image_prompt(draft, level),
negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(),
size: "1024*1024".to_string(),
asset_object_kind: "big_fish_level_main_image".to_string(),
binding_slot: format!("level_main_image:{level_part}"),
path_segments: vec![
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
"level-main-image".to_string(),
level_part,
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"level_motion" => {
let level = find_big_fish_level_blueprint(draft, level)?;
let motion_key = motion_key
.map(str::trim)
.filter(|value| matches!(*value, "idle_float" | "move_swim"))
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "motionKey 必须是 idle_float 或 move_swim。",
}))
})?;
let level_part = build_big_fish_level_part(Some(level.level));
Ok(BigFishFormalAssetContext {
entity_id: session.session_id.clone(),
prompt: build_big_fish_level_motion_prompt(draft, level, motion_key),
negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(),
size: "1024*1024".to_string(),
asset_object_kind: "big_fish_level_motion".to_string(),
binding_slot: format!("level_motion:{level_part}:{motion_key}"),
path_segments: vec![
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
"level-motion".to_string(),
level_part,
sanitize_big_fish_path_segment(motion_key, "motion"),
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"stage_background" => Ok(BigFishFormalAssetContext {
entity_id: session.session_id.clone(),
prompt: build_big_fish_stage_background_prompt(draft),
negative_prompt: BIG_FISH_DEFAULT_NEGATIVE_PROMPT.to_string(),
size: "720*1280".to_string(),
asset_object_kind: "big_fish_stage_background".to_string(),
binding_slot: "stage_background".to_string(),
path_segments: vec![
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
"stage-background".to_string(),
asset_id,
],
apply_transparent_background_post_process: false,
}),
_ => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"),
})),
),
}
}
fn find_big_fish_level_blueprint(
draft: &BigFishGameDraftRecord,
level: Option<u32>,
) -> Result<&BigFishLevelBlueprintRecord, AppError> {
let level = level.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "level 是等级资产生成的必填项。",
}))
})?;
draft
.levels
.iter()
.find(|blueprint| blueprint.level == level)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": format!("level `{level}` 不存在于当前 Big Fish 草稿。"),
}))
})
}
fn require_big_fish_dashscope_settings(
state: &AppState,
) -> Result<BigFishDashScopeSettings, AppError> {
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.dashscope_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": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(BigFishDashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
fn build_big_fish_dashscope_http_client(
settings: &BigFishDashScopeSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "dashscope",
"message": format!("构造 DashScope HTTP 客户端失败:{error}"),
}))
})
}
async fn create_big_fish_text_to_image_generation(
http_client: &reqwest::Client,
settings: &BigFishDashScopeSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
) -> Result<BigFishGeneratedImage, AppError> {
let mut parameters = Map::from_iter([
("n".to_string(), json!(1)),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
if !negative_prompt.trim().is_empty() {
parameters.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.trim().to_string()),
);
}
let response = http_client
.post(format!(
"{}/services/aigc/text2image/image-synthesis",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&json!({
"model": BIG_FISH_TEXT_TO_IMAGE_MODEL,
"input": {
"prompt": prompt,
},
"parameters": parameters,
}))
.send()
.await
.map_err(|error| {
map_big_fish_dashscope_request_error(format!("创建 Big Fish 图片生成任务失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("读取 Big Fish 图片生成响应失败:{error}"))
})?;
if !status.is_success() {
return Err(map_big_fish_dashscope_upstream_error(
response_text.as_str(),
"创建 Big Fish 图片生成任务失败",
));
}
let payload =
parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?;
let task_id = extract_big_fish_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "Big Fish 图片生成任务未返回 task_id",
}))
})?;
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_big_fish_dashscope_request_error(format!(
"查询 Big Fish 图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!(
"读取 Big Fish 图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_big_fish_dashscope_upstream_error(
poll_text.as_str(),
"查询 Big Fish 图片生成任务失败",
));
}
let poll_payload =
parse_big_fish_json_payload(poll_text.as_str(), "解析 Big Fish 图片生成任务响应失败")?;
let task_status = find_first_big_fish_string_by_key(&poll_payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_url = extract_big_fish_image_urls(&poll_payload)
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "Big Fish 图片生成成功但未返回图片地址",
}))
})?;
return Ok(BigFishGeneratedImage { image_url, task_id });
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_big_fish_dashscope_upstream_error(
poll_text.as_str(),
"Big Fish 图片生成任务失败",
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "Big Fish 图片生成超时或未返回图片地址",
})),
)
}
async fn download_big_fish_remote_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
apply_transparent_background_post_process: bool,
) -> Result<BigFishDownloadedImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
map_big_fish_dashscope_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("image/jpeg")
.to_string();
let bytes = response.bytes().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("{fallback_message}{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let mime_type = normalize_big_fish_downloaded_image_mime_type(content_type.as_str());
let mut normalized_bytes = bytes.to_vec();
let mut normalized_mime_type = mime_type;
// 中文注释Big Fish 的等级主图与动作关键帧要和 RPG 角色主图保持同一后处理口径。
// 因此在上游已经输出 PNG 时,统一补一层透明背景 alpha 清理,避免只靠 prompt 约束导致残留底色。
if apply_transparent_background_post_process
&& normalized_mime_type == "image/png"
&& let Some(optimized) = try_apply_background_alpha_to_png(normalized_bytes.as_slice())
{
normalized_bytes = optimized;
normalized_mime_type = "image/png".to_string();
}
Ok(BigFishDownloadedImage {
mime_type: normalized_mime_type,
bytes: normalized_bytes,
})
}
async fn persist_big_fish_formal_asset(
state: &AppState,
owner_user_id: &str,
context: &BigFishFormalAssetContext,
generated: BigFishGeneratedImage,
downloaded: BigFishDownloadedImage,
generated_at_micros: i64,
) -> Result<String, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let image_format = normalize_generated_image_asset_mime(downloaded.mime_type.as_str());
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::BigFishAssets,
path_segments: context.path_segments.clone(),
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: image_format,
bytes: downloaded.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(context.asset_object_kind.clone()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(BIG_FISH_ENTITY_KIND.to_string()),
entity_id: Some(context.entity_id.clone()),
slot: Some(context.binding_slot.clone()),
provider: Some("dashscope".to_string()),
task_id: Some(generated.task_id.clone()),
},
extra_metadata: BTreeMap::new(),
})
.map_err(map_big_fish_generated_image_asset_error)?;
let persisted_mime_type = prepared.format.mime_type.clone();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(map_big_fish_asset_oss_error)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_big_fish_asset_oss_error)?;
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(generated_at_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
context.asset_object_kind.clone(),
Some(generated.task_id),
Some(owner_user_id.to_string()),
None,
Some(context.entity_id.clone()),
generated_at_micros,
)
.map_err(map_big_fish_asset_object_prepare_error)?,
)
.await
.map_err(map_big_fish_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(generated_at_micros),
asset_object.asset_object_id,
BIG_FISH_ENTITY_KIND.to_string(),
context.entity_id.clone(),
context.binding_slot.clone(),
context.asset_object_kind.clone(),
Some(owner_user_id.to_string()),
None,
generated_at_micros,
)
.map_err(map_big_fish_asset_binding_prepare_error)?,
)
.await
.map_err(map_big_fish_asset_spacetime_error)?;
Ok(put_result.legacy_public_path)
}
fn map_big_fish_generated_image_asset_error(
error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError,
) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备 Big Fish 图片资产上传请求失败:{error:?}"),
}))
}
fn parse_big_fish_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": format!("{fallback_message}{error}"),
}))
})
}
fn extract_big_fish_task_id(payload: &Value) -> Option<String> {
find_first_big_fish_string_by_key(payload, "task_id")
}
fn extract_big_fish_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_big_fish_strings_by_key(payload, "image", &mut urls);
collect_big_fish_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn find_first_big_fish_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_big_fish_strings_by_key(payload, target_key, &mut results);
results.into_iter().next()
}
fn collect_big_fish_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_big_fish_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, value) in object {
if key == target_key
&& let Some(text) = value.as_str()
{
results.push(text.to_string());
}
collect_big_fish_strings_by_key(value, target_key, results);
}
}
_ => {}
}
}
fn normalize_big_fish_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
fn map_big_fish_dashscope_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": message,
}))
}
fn map_big_fish_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_big_fish_api_error_message(raw_text, fallback_message),
}))
}
fn parse_big_fish_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
&& let Some(message) = find_first_big_fish_string_by_key(&payload, "message")
.or_else(|| find_first_big_fish_string_by_key(&payload, "code"))
{
return message;
}
let excerpt = trimmed.chars().take(240).collect::<String>();
format!("{fallback_message}{excerpt}")
}
fn map_big_fish_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn build_big_fish_level_part(level: Option<u32>) -> String {
level
.map(|value| format!("level-{value}"))
.unwrap_or_else(|| "stage".to_string())
}

View File

@@ -0,0 +1,321 @@
use super::*;
pub(super) fn map_big_fish_session_response(session: BigFishSessionRecord) -> BigFishSessionSnapshotResponse {
BigFishSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack: map_big_fish_anchor_pack_response(session.anchor_pack),
draft: session.draft.map(map_big_fish_draft_response),
asset_slots: session
.asset_slots
.into_iter()
.map(map_big_fish_asset_slot_response)
.collect(),
asset_coverage: map_big_fish_asset_coverage_response(session.asset_coverage),
messages: session
.messages
.into_iter()
.map(map_big_fish_agent_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
publish_ready: session.publish_ready,
updated_at: session.updated_at,
}
}
pub(super) fn map_big_fish_anchor_pack_response(
anchor_pack: BigFishAnchorPackRecord,
) -> BigFishAnchorPackResponse {
BigFishAnchorPackResponse {
gameplay_promise: map_big_fish_anchor_item_response(anchor_pack.gameplay_promise),
ecology_visual_theme: map_big_fish_anchor_item_response(anchor_pack.ecology_visual_theme),
growth_ladder: map_big_fish_anchor_item_response(anchor_pack.growth_ladder),
risk_tempo: map_big_fish_anchor_item_response(anchor_pack.risk_tempo),
}
}
pub(super) fn map_big_fish_anchor_item_response(anchor: BigFishAnchorItemRecord) -> BigFishAnchorItemResponse {
BigFishAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_big_fish_draft_response(draft: BigFishGameDraftRecord) -> BigFishGameDraftResponse {
BigFishGameDraftResponse {
title: draft.title,
subtitle: draft.subtitle,
core_fun: draft.core_fun,
ecology_theme: draft.ecology_theme,
levels: draft
.levels
.into_iter()
.map(map_big_fish_level_response)
.collect(),
background: map_big_fish_background_response(draft.background),
runtime_params: map_big_fish_runtime_params_response(draft.runtime_params),
}
}
pub(super) fn map_big_fish_level_response(
level: BigFishLevelBlueprintRecord,
) -> BigFishLevelBlueprintResponse {
BigFishLevelBlueprintResponse {
level: level.level,
name: level.name,
one_line_fantasy: level.one_line_fantasy,
text_description: level.text_description,
silhouette_direction: level.silhouette_direction,
size_ratio: level.size_ratio,
visual_description: level.visual_description,
visual_prompt_seed: level.visual_prompt_seed,
idle_motion_description: level.idle_motion_description,
move_motion_description: level.move_motion_description,
motion_prompt_seed: level.motion_prompt_seed,
merge_source_level: level.merge_source_level,
prey_window: level.prey_window,
threat_window: level.threat_window,
is_final_level: level.is_final_level,
}
}
pub(super) fn map_big_fish_background_response(
background: BigFishBackgroundBlueprintRecord,
) -> BigFishBackgroundBlueprintResponse {
BigFishBackgroundBlueprintResponse {
theme: background.theme,
color_mood: background.color_mood,
foreground_hints: background.foreground_hints,
midground_composition: background.midground_composition,
background_depth: background.background_depth,
safe_play_area_hint: background.safe_play_area_hint,
spawn_edge_hint: background.spawn_edge_hint,
background_prompt_seed: background.background_prompt_seed,
}
}
pub(super) fn map_big_fish_runtime_params_response(
params: BigFishRuntimeParamsRecord,
) -> BigFishRuntimeParamsResponse {
BigFishRuntimeParamsResponse {
level_count: params.level_count,
merge_count_per_upgrade: params.merge_count_per_upgrade,
spawn_target_count: params.spawn_target_count,
leader_move_speed: params.leader_move_speed,
follower_catch_up_speed: params.follower_catch_up_speed,
offscreen_cull_seconds: params.offscreen_cull_seconds,
prey_spawn_delta_levels: params.prey_spawn_delta_levels,
threat_spawn_delta_levels: params.threat_spawn_delta_levels,
win_level: params.win_level,
}
}
pub(super) fn map_big_fish_asset_slot_response(slot: BigFishAssetSlotRecord) -> BigFishAssetSlotResponse {
BigFishAssetSlotResponse {
slot_id: slot.slot_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at: slot.updated_at,
}
}
pub(super) fn map_big_fish_asset_coverage_response(
coverage: BigFishAssetCoverageRecord,
) -> BigFishAssetCoverageResponse {
BigFishAssetCoverageResponse {
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
required_level_count: coverage.required_level_count,
publish_ready: coverage.publish_ready,
blockers: coverage.blockers,
}
}
pub(super) fn map_big_fish_run_response(run: BigFishRuntimeRunRecord) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
pub(super) fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
pub(super) fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
pub(super) async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
owner_user_id: String,
now: i64,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
// 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。
// 这些资产统一留在结果页工坊按需触发,避免 compile action 因长耗时资产任务卡在首步等待态。
let session =
load_big_fish_session_with_retry(state, session_id.clone(), owner_user_id.clone()).await?;
let anchor_pack = map_record_anchor_pack_to_domain(&session.anchor_pack);
let compiled_draft =
compile_big_fish_draft_with_fallback(state.llm_client(), &anchor_pack).await;
let draft_json = serde_json::to_string(&compiled_draft).ok();
state
.spacetime_client()
.compile_big_fish_draft(BigFishDraftCompileRecordInput {
session_id,
owner_user_id,
draft_json,
compiled_at_micros: now,
})
.await
}
pub(super) async fn load_big_fish_session_with_retry(
state: &AppState,
session_id: String,
owner_user_id: String,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let mut last_retryable_error = None;
for attempt in 0..2 {
match state
.spacetime_client()
.get_big_fish_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => return Ok(session),
Err(error @ SpacetimeClientError::Timeout)
| Err(error @ SpacetimeClientError::ConnectDropped) => {
last_retryable_error = Some(error);
if attempt == 0 {
sleep(Duration::from_millis(250)).await;
continue;
}
}
Err(error) => return Err(error),
}
}
Err(last_retryable_error.unwrap_or(SpacetimeClientError::Timeout))
}
pub(super) fn map_record_anchor_pack_to_domain(
anchor_pack: &BigFishAnchorPackRecord,
) -> module_big_fish::BigFishAnchorPack {
module_big_fish::BigFishAnchorPack {
gameplay_promise: map_record_anchor_item_to_domain(&anchor_pack.gameplay_promise),
ecology_visual_theme: map_record_anchor_item_to_domain(&anchor_pack.ecology_visual_theme),
growth_ladder: map_record_anchor_item_to_domain(&anchor_pack.growth_ladder),
risk_tempo: map_record_anchor_item_to_domain(&anchor_pack.risk_tempo),
}
}
pub(super) fn map_record_anchor_item_to_domain(
anchor_item: &BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: anchor_item.key.clone(),
label: anchor_item.label.clone(),
value: anchor_item.value.clone(),
status: match anchor_item.status.as_str() {
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
_ => module_big_fish::BigFishAnchorStatus::Missing,
},
}
}
pub(super) fn map_big_fish_agent_message_response(
message: BigFishAgentMessageRecord,
) -> BigFishAgentMessageResponse {
BigFishAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_big_fish_work_summary_response(
state: &AppState,
item: BigFishWorkSummaryRecord,
) -> BigFishWorkSummaryResponse {
let author = resolve_work_author_by_user_id(state, &item.owner_user_id, None, None);
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
owner_user_id: item.owner_user_id,
author_display_name: author.display_name,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
published_at: item
.published_at_micros
.map(current_timestamp_micros_to_string),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
}
}
pub(super) fn build_big_fish_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。"
.to_string();
}
"我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string()
}