Files
Genarrative/server-rs/crates/spacetime-client/src/wooden_fish.rs

1313 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use crate::mapper::{
map_wooden_fish_agent_session_procedure_result, map_wooden_fish_gallery_card_view_row,
map_wooden_fish_run_procedure_result, map_wooden_fish_work_procedure_result,
map_wooden_fish_works_procedure_result,
};
use shared_contracts::wooden_fish::{
WoodenFishActionRequest, WoodenFishActionResponse, WoodenFishActionType,
WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest,
WoodenFishGalleryResponse, WoodenFishGenerationStatus, WoodenFishRuntimeRunSnapshotResponse,
WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWorkProfileResponse,
};
use shared_kernel::build_prefixed_uuid_id;
const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish";
const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼";
const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景";
impl SpacetimeClient {
pub async fn create_wooden_fish_session(
&self,
session: WoodenFishSessionSnapshotResponse,
) -> Result<WoodenFishSessionSnapshotResponse, SpacetimeClientError> {
let draft = session.draft.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("wooden-fish session 缺少 draft")
})?;
let theme_tags_json = Some(json_string(&draft.theme_tags)?);
let config_json = Some(build_config_json(&draft)?);
let draft_json = Some(json_string(&draft)?);
let procedure_input = WoodenFishAgentSessionCreateInput {
session_id: session.session_id,
owner_user_id: session.owner_user_id,
work_title: draft.work_title,
work_description: draft.work_description,
theme_tags_json,
config_json,
draft_json,
created_at_micros: current_unix_micros(),
};
self.call_after_connect(
"create_wooden_fish_agent_session",
move |connection, sender| {
connection
.procedures()
.create_wooden_fish_agent_session_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_agent_session_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn get_wooden_fish_session(
&self,
session_id: String,
owner_user_id: String,
) -> Result<WoodenFishSessionSnapshotResponse, SpacetimeClientError> {
let procedure_input = WoodenFishAgentSessionGetInput {
session_id,
owner_user_id,
};
self.call_after_connect(
"get_wooden_fish_agent_session",
move |connection, sender| {
connection.procedures().get_wooden_fish_agent_session_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn execute_wooden_fish_action(
&self,
session_id: String,
owner_user_id: String,
author_display_name: String,
payload: WoodenFishActionRequest,
) -> Result<WoodenFishActionResponse, SpacetimeClientError> {
let current = self
.get_wooden_fish_session(session_id.clone(), owner_user_id.clone())
.await?;
let (procedure, _) = build_wooden_fish_action_plan(
&current,
&owner_user_id,
&author_display_name,
&payload,
current_unix_micros(),
)?;
let (session, work) = match procedure {
WoodenFishActionProcedure::Compile(input) => {
let profile_id = input.profile_id.clone();
let session = self.compile_wooden_fish_draft(input).await?;
let work = self
.get_wooden_fish_work_profile(profile_id, owner_user_id)
.await
.ok();
(session, work)
}
WoodenFishActionProcedure::Update(input) => {
let work = self.update_wooden_fish_work(input).await?;
let session = apply_wooden_fish_work_to_session(current, &work);
(session, Some(work))
}
};
Ok(WoodenFishActionResponse {
action_type: payload.action_type,
session,
work,
})
}
pub async fn mark_wooden_fish_generation_failed(
&self,
session_id: String,
owner_user_id: String,
author_display_name: String,
) -> Result<WoodenFishSessionSnapshotResponse, SpacetimeClientError> {
let current = self
.get_wooden_fish_session(session_id.clone(), owner_user_id.clone())
.await?;
let mut draft = current.draft.clone().unwrap_or_else(default_draft);
let profile_id = resolve_wooden_fish_profile_id(
&draft,
&WoodenFishActionType::CompileDraft,
draft.profile_id.as_deref(),
)?;
draft.profile_id = Some(profile_id.clone());
draft.generation_status = WoodenFishGenerationStatus::Failed;
let now_micros = current_unix_micros();
self.compile_wooden_fish_draft(build_failed_compile_input(
&current,
&owner_user_id,
&author_display_name,
&profile_id,
&draft,
now_micros,
)?)
.await
}
pub async fn compile_wooden_fish_draft(
&self,
procedure_input: WoodenFishDraftCompileInput,
) -> Result<WoodenFishSessionSnapshotResponse, SpacetimeClientError> {
self.call_after_connect("compile_wooden_fish_draft", move |connection, sender| {
connection.procedures().compile_wooden_fish_draft_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn get_wooden_fish_work_profile(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
let procedure_input = WoodenFishWorkGetInput {
profile_id,
owner_user_id,
};
self.call_after_connect("get_wooden_fish_work_profile", move |connection, sender| {
connection.procedures().get_wooden_fish_work_profile_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn update_wooden_fish_work(
&self,
procedure_input: WoodenFishWorkUpdateInput,
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
self.call_after_connect("update_wooden_fish_work", move |connection, sender| {
connection.procedures().update_wooden_fish_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn publish_wooden_fish_work(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
let procedure_input = WoodenFishWorkPublishInput {
profile_id,
owner_user_id,
published_at_micros: current_unix_micros(),
};
self.call_after_connect("publish_wooden_fish_work", move |connection, sender| {
connection.procedures().publish_wooden_fish_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn list_wooden_fish_works(
&self,
owner_user_id: String,
) -> Result<Vec<WoodenFishWorkProfileResponse>, SpacetimeClientError> {
let procedure_input = WoodenFishWorksListInput {
owner_user_id,
published_only: false,
};
self.call_after_connect("list_wooden_fish_works", move |connection, sender| {
connection.procedures().list_wooden_fish_works_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_works_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn get_wooden_fish_runtime_work(
&self,
profile_id: String,
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
self.get_wooden_fish_work_profile(profile_id, String::new())
.await
}
pub async fn start_wooden_fish_run(
&self,
payload: WoodenFishStartRunRequest,
owner_user_id: String,
) -> Result<WoodenFishRuntimeRunSnapshotResponse, SpacetimeClientError> {
let run_id = build_prefixed_uuid_id("wooden-fish-run-");
let procedure_input = WoodenFishRunStartInput {
client_event_id: format!("{run_id}:start"),
run_id,
owner_user_id,
profile_id: payload.profile_id,
started_at_ms: current_unix_micros().div_euclid(1000),
};
self.start_wooden_fish_run_with_input(procedure_input).await
}
pub async fn start_wooden_fish_run_with_input(
&self,
procedure_input: WoodenFishRunStartInput,
) -> Result<WoodenFishRuntimeRunSnapshotResponse, SpacetimeClientError> {
self.call_after_connect("start_wooden_fish_run", move |connection, sender| {
connection.procedures().start_wooden_fish_run_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_run_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn checkpoint_wooden_fish_run(
&self,
run_id: String,
owner_user_id: String,
payload: WoodenFishCheckpointRunRequest,
) -> Result<WoodenFishRuntimeRunSnapshotResponse, SpacetimeClientError> {
let procedure_input = WoodenFishRunCheckpointInput {
run_id,
owner_user_id,
total_tap_count: payload.total_tap_count,
word_counters_json: json_string(&payload.word_counters)?,
client_event_id: payload.client_event_id,
checkpoint_at_ms: current_unix_micros().div_euclid(1000),
};
self.call_after_connect("checkpoint_wooden_fish_run", move |connection, sender| {
connection.procedures().checkpoint_wooden_fish_run_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_run_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn finish_wooden_fish_run(
&self,
run_id: String,
owner_user_id: String,
payload: WoodenFishFinishRunRequest,
) -> Result<WoodenFishRuntimeRunSnapshotResponse, SpacetimeClientError> {
let procedure_input = WoodenFishRunFinishInput {
run_id,
owner_user_id,
total_tap_count: payload.total_tap_count,
word_counters_json: json_string(&payload.word_counters)?,
client_event_id: payload.client_event_id,
finished_at_ms: current_unix_micros().div_euclid(1000),
};
self.call_after_connect("finish_wooden_fish_run", move |connection, sender| {
connection.procedures().finish_wooden_fish_run_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_wooden_fish_run_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn list_wooden_fish_gallery(
&self,
) -> Result<WoodenFishGalleryResponse, SpacetimeClientError> {
self.read_after_connect("list_wooden_fish_gallery", move |connection| {
let mut items = connection
.db()
.wooden_fish_gallery_card_view()
.iter()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
Ok(WoodenFishGalleryResponse {
items: items
.into_iter()
.map(map_wooden_fish_gallery_card_view_row)
.collect(),
has_more: false,
next_cursor: None,
})
})
.await
}
pub async fn get_wooden_fish_gallery_detail(
&self,
public_work_code: String,
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
let normalized_code = public_work_code.trim().to_string();
if normalized_code.is_empty() {
return Err(SpacetimeClientError::validation_failed(
"wooden-fish public_work_code 不能为空",
));
}
let profile_id = self
.read_after_connect("resolve_wooden_fish_gallery_detail", move |connection| {
connection
.db()
.wooden_fish_gallery_card_view()
.iter()
.find(|row| {
row.public_work_code.eq_ignore_ascii_case(&normalized_code)
|| row.profile_id == normalized_code
})
.map(|row| row.profile_id)
.ok_or_else(|| {
SpacetimeClientError::procedure_failed(Some(
"敲木鱼公开作品不存在".to_string(),
))
})
})
.await?;
self.get_wooden_fish_work_profile(profile_id, String::new())
.await
}
}
enum WoodenFishActionProcedure {
Compile(WoodenFishDraftCompileInput),
Update(WoodenFishWorkUpdateInput),
}
#[derive(Clone, Copy)]
enum WoodenFishDraftMergeScope {
CompileDraft,
RegenerateHitObject,
GenerateHitSound,
ReplaceHitSound,
UpdateWorkMeta,
UpdateFloatingWords,
}
#[derive(Clone, Copy)]
enum WoodenFishAssetRefresh {
Preserve,
HitObject,
HitSound,
}
fn build_wooden_fish_action_plan(
current: &WoodenFishSessionSnapshotResponse,
owner_user_id: &str,
author_display_name: &str,
payload: &WoodenFishActionRequest,
now_micros: i64,
) -> Result<(WoodenFishActionProcedure, WoodenFishDraftResponse), SpacetimeClientError> {
let scope = match payload.action_type {
WoodenFishActionType::CompileDraft => WoodenFishDraftMergeScope::CompileDraft,
WoodenFishActionType::RegenerateHitObject => WoodenFishDraftMergeScope::RegenerateHitObject,
WoodenFishActionType::GenerateHitSound => WoodenFishDraftMergeScope::GenerateHitSound,
WoodenFishActionType::ReplaceHitSound => WoodenFishDraftMergeScope::ReplaceHitSound,
WoodenFishActionType::UpdateWorkMeta => WoodenFishDraftMergeScope::UpdateWorkMeta,
WoodenFishActionType::UpdateFloatingWords => WoodenFishDraftMergeScope::UpdateFloatingWords,
};
let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?;
let profile_id = resolve_wooden_fish_profile_id(
&draft,
&payload.action_type,
payload.profile_id.as_deref(),
)?;
draft.profile_id = Some(profile_id.clone());
let procedure = match payload.action_type {
WoodenFishActionType::CompileDraft => {
WoodenFishActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
author_display_name,
&profile_id,
&mut draft,
WoodenFishAssetRefresh::Preserve,
now_micros,
)?)
}
WoodenFishActionType::RegenerateHitObject => {
WoodenFishActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
author_display_name,
&profile_id,
&mut draft,
WoodenFishAssetRefresh::HitObject,
now_micros,
)?)
}
WoodenFishActionType::GenerateHitSound => {
WoodenFishActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
author_display_name,
&profile_id,
&mut draft,
WoodenFishAssetRefresh::HitSound,
now_micros,
)?)
}
WoodenFishActionType::ReplaceHitSound => WoodenFishActionProcedure::Update(
build_update_input(owner_user_id, &profile_id, &draft, true, now_micros)?,
),
WoodenFishActionType::UpdateWorkMeta | WoodenFishActionType::UpdateFloatingWords => {
WoodenFishActionProcedure::Update(build_update_input(
owner_user_id,
&profile_id,
&draft,
false,
now_micros,
)?)
}
};
Ok((procedure, draft))
}
fn merge_action_into_draft(
draft: Option<WoodenFishDraftResponse>,
payload: &WoodenFishActionRequest,
scope: WoodenFishDraftMergeScope,
) -> Result<WoodenFishDraftResponse, SpacetimeClientError> {
let mut draft = draft.unwrap_or_else(default_draft);
if matches!(
scope,
WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::UpdateWorkMeta
) {
if let Some(value) = payload
.work_title
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.work_title = value.trim().to_string();
}
if let Some(value) = payload.work_description.as_ref() {
draft.work_description = value.trim().to_string();
}
if let Some(value) = payload.theme_tags.clone() {
draft.theme_tags = normalize_tags(value);
}
}
if matches!(
scope,
WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::RegenerateHitObject
) {
if let Some(value) = payload
.hit_object_prompt
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.hit_object_prompt = value.trim().to_string();
}
if payload.hit_object_reference_image_src.is_some() {
draft.hit_object_reference_image_src = payload
.hit_object_reference_image_src
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
}
if let Some(asset) = payload.hit_object_asset.clone() {
draft.hit_object_asset = Some(asset);
}
if let Some(asset) = payload.background_asset.clone() {
draft.background_asset = Some(asset);
}
if let Some(asset) = payload.back_button_asset.clone() {
draft.back_button_asset = Some(asset);
}
}
draft.hit_sound_prompt = None;
if matches!(scope, WoodenFishDraftMergeScope::GenerateHitSound) {
draft.hit_sound_asset = payload.hit_sound_asset.clone();
} else if matches!(
scope,
WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::ReplaceHitSound
) && let Some(asset) = payload.hit_sound_asset.clone()
{
draft.hit_sound_asset = Some(asset);
}
if matches!(
scope,
WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::UpdateFloatingWords
) && let Some(words) = payload.floating_words.clone()
{
draft.floating_words = normalize_floating_words(words);
}
if draft.work_title.trim().is_empty() {
return Err(SpacetimeClientError::validation_failed(
"wooden-fish work_title 不能为空",
));
}
if draft.hit_object_prompt.trim().is_empty() {
draft.hit_object_prompt = DEFAULT_HIT_OBJECT_PROMPT.to_string();
}
if draft.hit_object_asset.is_some()
&& matches!(scope, WoodenFishDraftMergeScope::RegenerateHitObject)
&& payload.hit_object_asset.is_none()
{
draft.hit_object_asset = None;
draft.background_asset = None;
draft.back_button_asset = None;
}
if draft.floating_words.is_empty() {
draft.floating_words = default_floating_words();
}
Ok(draft)
}
fn build_compile_input(
current: &WoodenFishSessionSnapshotResponse,
owner_user_id: &str,
author_display_name: &str,
profile_id: &str,
draft: &mut WoodenFishDraftResponse,
refresh: WoodenFishAssetRefresh,
now_micros: i64,
) -> Result<WoodenFishDraftCompileInput, SpacetimeClientError> {
let _refresh = refresh;
let hit_object_asset = draft.hit_object_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("wooden fish hit object asset 缺少真实生成资产")
})?;
draft.hit_object_asset = Some(hit_object_asset);
draft.cover_image_src = draft
.hit_object_asset
.as_ref()
.map(|asset| asset.image_src.clone());
draft.generation_status = WoodenFishGenerationStatus::Ready;
let hit_object_asset = draft
.hit_object_asset
.clone()
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?;
let hit_sound_asset = draft.hit_sound_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("wooden fish hit sound asset 缺少真实生成资产")
})?;
let background_asset = draft.background_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("wooden fish background asset 缺少真实生成资产")
})?;
let back_button_asset = draft.back_button_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("wooden fish back button asset 缺少真实生成资产")
})?;
Ok(WoodenFishDraftCompileInput {
session_id: current.session_id.clone(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
author_display_name: author_display_name.trim().to_string(),
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
hit_object_prompt: draft.hit_object_prompt.clone(),
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
hit_sound_prompt: draft.hit_sound_prompt.clone(),
hit_object_asset_json: Some(json_string(&hit_object_asset)?),
background_asset_json: Some(json_string(&background_asset)?),
hit_sound_asset_json: Some(json_string(&hit_sound_asset)?),
back_button_asset_json: Some(json_string(&back_button_asset)?),
floating_words_json: Some(json_string(&draft.floating_words)?),
cover_image_src: draft.cover_image_src.clone(),
generation_status: Some("ready".to_string()),
compiled_at_micros: now_micros,
})
}
fn build_failed_compile_input(
current: &WoodenFishSessionSnapshotResponse,
owner_user_id: &str,
author_display_name: &str,
profile_id: &str,
draft: &WoodenFishDraftResponse,
now_micros: i64,
) -> Result<WoodenFishDraftCompileInput, SpacetimeClientError> {
Ok(WoodenFishDraftCompileInput {
session_id: current.session_id.clone(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
author_display_name: author_display_name.trim().to_string(),
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
hit_object_prompt: draft.hit_object_prompt.clone(),
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
hit_sound_prompt: draft.hit_sound_prompt.clone(),
hit_object_asset_json: draft
.hit_object_asset
.as_ref()
.map(json_string)
.transpose()?,
background_asset_json: draft
.background_asset
.as_ref()
.map(json_string)
.transpose()?,
hit_sound_asset_json: draft
.hit_sound_asset
.as_ref()
.map(json_string)
.transpose()?,
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
floating_words_json: Some(json_string(&draft.floating_words)?),
cover_image_src: draft.cover_image_src.clone(),
generation_status: Some("failed".to_string()),
compiled_at_micros: now_micros,
})
}
fn build_update_input(
owner_user_id: &str,
profile_id: &str,
draft: &WoodenFishDraftResponse,
include_hit_sound_asset: bool,
now_micros: i64,
) -> Result<WoodenFishWorkUpdateInput, SpacetimeClientError> {
Ok(WoodenFishWorkUpdateInput {
profile_id: profile_id.to_string(),
owner_user_id: owner_user_id.to_string(),
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: json_string(&draft.theme_tags)?,
hit_object_prompt: Some(draft.hit_object_prompt.clone()),
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
hit_sound_prompt: draft.hit_sound_prompt.clone(),
hit_object_asset_json: None,
background_asset_json: None,
hit_sound_asset_json: if include_hit_sound_asset {
draft
.hit_sound_asset
.as_ref()
.map(json_string)
.transpose()?
} else {
None
},
back_button_asset_json: None,
floating_words_json: Some(json_string(&draft.floating_words)?),
cover_image_src: draft.cover_image_src.clone(),
generation_status: None,
updated_at_micros: now_micros,
})
}
fn resolve_wooden_fish_profile_id(
draft: &WoodenFishDraftResponse,
action_type: &WoodenFishActionType,
requested_profile_id: Option<&str>,
) -> Result<String, SpacetimeClientError> {
if let Some(profile_id) = requested_profile_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(profile_id.to_string());
}
if let Some(profile_id) = draft
.profile_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
return Ok(profile_id.to_string());
}
if matches!(action_type, WoodenFishActionType::CompileDraft) {
return Ok(build_prefixed_uuid_id("wooden-fish-profile-"));
}
Err(SpacetimeClientError::validation_failed(
"wooden-fish action 需要先完成 compile-draft",
))
}
fn apply_wooden_fish_work_to_session(
mut session: WoodenFishSessionSnapshotResponse,
work: &WoodenFishWorkProfileResponse,
) -> WoodenFishSessionSnapshotResponse {
session.status = work.draft.generation_status.clone();
session.draft = Some(work.draft.clone());
session.updated_at = work.summary.updated_at.clone();
session
}
fn default_draft() -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: WOODEN_FISH_TEMPLATE_NAME.to_string(),
work_description: String::new(),
theme_tags: vec!["休闲".to_string()],
hit_object_prompt: DEFAULT_HIT_OBJECT_PROMPT.to_string(),
hit_object_reference_image_src: None,
hit_sound_prompt: None,
floating_words: default_floating_words(),
hit_object_asset: None,
background_asset: None,
back_button_asset: None,
hit_sound_asset: None,
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,
}
}
fn build_config_json(draft: &WoodenFishDraftResponse) -> Result<String, SpacetimeClientError> {
serde_json::to_string(&serde_json::json!({
"workTitle": draft.work_title,
"workDescription": draft.work_description,
"themeTags": draft.theme_tags,
"hitObjectPrompt": draft.hit_object_prompt,
"hitObjectReferenceImageSrc": draft.hit_object_reference_image_src,
"hitSoundPrompt": draft.hit_sound_prompt,
"floatingWords": draft.floating_words,
}))
.map_err(SpacetimeClientError::validation_failed)
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
tags.into_iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.take(8)
.collect()
}
fn default_floating_words() -> Vec<String> {
vec![
"幸运".to_string(),
"健康".to_string(),
"财富".to_string(),
"姻缘".to_string(),
"幸福".to_string(),
"事业".to_string(),
"成功".to_string(),
"功德".to_string(),
]
}
fn normalize_floating_words(words: Vec<String>) -> Vec<String> {
let mut normalized = Vec::new();
for word in words {
let word = normalize_floating_word(&word);
if word.is_empty() || normalized.iter().any(|item| item == &word) {
continue;
}
normalized.push(word);
if normalized.len() >= 8 {
break;
}
}
if normalized.is_empty() {
default_floating_words()
} else {
normalized
}
}
fn normalize_floating_word(word: &str) -> String {
word.trim()
.trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace())
.trim_end_matches(['+', ''])
.trim()
.to_string()
}
fn json_string<T: serde::Serialize>(value: &T) -> Result<String, SpacetimeClientError> {
serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed)
}
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::wooden_fish::WoodenFishAudioAsset;
const SESSION_ID: &str = "wooden-fish-session-test";
const OWNER_USER_ID: &str = "user-test";
const AUTHOR_DISPLAY_NAME: &str = "测试玩家";
const PROFILE_ID: &str = "wooden-fish-profile-test";
const NOW_MICROS: i64 = 1_763_456_789_000_000;
#[test]
fn wooden_fish_action_compile_draft_builds_compile_input_with_assets() {
let session = session_with_draft(draft_without_assets());
let mut payload = action(WoodenFishActionType::CompileDraft);
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
let (plan, draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("compile-draft should build plan");
let WoodenFishActionProcedure::Compile(input) = plan else {
panic!("compile-draft should call compile_wooden_fish_draft");
};
assert_eq!(input.session_id, SESSION_ID);
assert_eq!(input.owner_user_id, OWNER_USER_ID);
assert_eq!(input.generation_status.as_deref(), Some("ready"));
assert!(
input
.hit_object_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-compile-object")
);
assert!(
input
.hit_sound_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-compile-sound")
);
assert!(
input
.background_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-compile-background")
);
assert!(
input
.back_button_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-compile-back")
);
assert_eq!(draft.generation_status, WoodenFishGenerationStatus::Ready);
}
#[test]
fn wooden_fish_compile_requires_real_hit_sound_asset_from_api_server() {
let session = session_with_draft(draft_without_assets());
let mut payload = action(WoodenFishActionType::CompileDraft);
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("hit sound asset 缺少真实生成资产")
);
}
#[test]
fn wooden_fish_compile_requires_real_background_asset_from_api_server() {
let session = session_with_draft(draft_without_assets());
let mut payload = action(WoodenFishActionType::CompileDraft);
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not publish without background asset"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("background asset 缺少真实生成资产")
);
}
#[test]
fn wooden_fish_compile_requires_real_back_button_asset_from_api_server() {
let session = session_with_draft(draft_without_assets());
let mut payload = action(WoodenFishActionType::CompileDraft);
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not publish without back button asset"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("back button asset 缺少真实生成资产")
);
}
#[test]
fn wooden_fish_action_regenerate_hit_object_replaces_only_object_asset() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(WoodenFishActionType::RegenerateHitObject);
payload.hit_object_prompt = Some("新的敲击物".to_string());
payload.hit_object_asset = Some(generated_hit_object_asset("generated-object"));
payload.background_asset = Some(generated_background_asset("generated-background"));
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
let (plan, _draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("regenerate-hit-object should build plan");
let WoodenFishActionProcedure::Compile(input) = plan else {
panic!("regenerate-hit-object should call compile_wooden_fish_draft");
};
assert!(
!input
.hit_object_asset_json
.as_deref()
.unwrap_or("")
.contains("old-object")
);
assert!(
input
.hit_object_asset_json
.as_deref()
.unwrap_or("")
.contains("real-profile")
);
assert!(
!input
.hit_object_asset_json
.as_deref()
.unwrap_or("")
.contains(&NOW_MICROS.to_string())
);
assert!(
input
.hit_sound_asset_json
.as_deref()
.unwrap_or("")
.contains("old-sound")
);
assert!(
input
.background_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-background")
);
assert!(
input
.back_button_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-back")
);
}
#[test]
fn wooden_fish_action_update_floating_words_builds_update_input() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(WoodenFishActionType::UpdateFloatingWords);
payload.floating_words = Some(vec![
" 功德+1 ".to_string(),
"功德+1".to_string(),
"健康+1".to_string(),
]);
let (plan, draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("update-floating-words should build plan");
let WoodenFishActionProcedure::Update(input) = plan else {
panic!("update-floating-words should call update_wooden_fish_work");
};
assert_eq!(input.profile_id, PROFILE_ID);
assert!(input.hit_sound_asset_json.is_none());
assert_eq!(
draft.floating_words,
vec!["功德".to_string(), "健康".to_string()]
);
assert!(
input
.floating_words_json
.as_deref()
.unwrap_or("")
.contains("健康")
);
}
#[test]
fn wooden_fish_default_draft_has_no_hit_sound_prompt() {
let draft = default_draft();
assert!(draft.hit_sound_prompt.is_none());
}
#[test]
fn wooden_fish_failed_compile_input_preserves_session_and_marks_failed() {
let session = session_with_draft(draft_without_assets());
let mut draft = session.draft.clone().expect("draft should exist");
draft.profile_id = Some(PROFILE_ID.to_string());
draft.generation_status = WoodenFishGenerationStatus::Failed;
let input = build_failed_compile_input(
&session,
OWNER_USER_ID,
"测试玩家",
PROFILE_ID,
&draft,
NOW_MICROS,
)
.expect("failed compile input should build");
assert_eq!(input.session_id, SESSION_ID);
assert_eq!(input.profile_id, PROFILE_ID);
assert_eq!(input.generation_status.as_deref(), Some("failed"));
assert!(input.hit_object_asset_json.is_none());
assert!(input.background_asset_json.is_none());
assert!(input.back_button_asset_json.is_none());
}
fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest {
WoodenFishActionRequest {
action_type,
profile_id: None,
work_title: None,
work_description: None,
theme_tags: None,
hit_object_prompt: None,
hit_object_reference_image_src: None,
hit_object_asset: None,
background_asset: None,
back_button_asset: None,
hit_sound_prompt: None,
hit_sound_asset: None,
floating_words: None,
}
}
fn session_with_draft(draft: WoodenFishDraftResponse) -> WoodenFishSessionSnapshotResponse {
WoodenFishSessionSnapshotResponse {
session_id: SESSION_ID.to_string(),
owner_user_id: OWNER_USER_ID.to_string(),
status: draft.generation_status.clone(),
draft: Some(draft),
created_at: "2026-05-20T00:00:00Z".to_string(),
updated_at: "2026-05-20T00:00:00Z".to_string(),
}
}
fn draft_without_assets() -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
profile_id: None,
..base_draft()
}
}
fn generated_hit_object_asset(asset_id: &str) -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: asset_id.to_string(),
image_src: "/generated-wooden-fish-assets/real-profile/hit-object/image.png"
.to_string(),
image_object_key: "generated-wooden-fish-assets/real-profile/hit-object/image.png"
.to_string(),
asset_object_id: format!("{asset_id}-asset"),
generation_provider: "image2".to_string(),
prompt: "新的敲击物".to_string(),
width: 1024,
height: 1024,
}
}
fn generated_background_asset(asset_id: &str) -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: asset_id.to_string(),
image_src: "/generated-wooden-fish-assets/real-profile/background/image.png"
.to_string(),
image_object_key: "generated-wooden-fish-assets/real-profile/background/image.png"
.to_string(),
asset_object_id: format!("{asset_id}-asset"),
generation_provider: "image2".to_string(),
prompt: "新的敲击背景".to_string(),
width: 1024,
height: 1536,
}
}
fn generated_back_button_asset(asset_id: &str) -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: asset_id.to_string(),
image_src: "/generated-wooden-fish-assets/real-profile/back-button/image.png"
.to_string(),
image_object_key: "generated-wooden-fish-assets/real-profile/back-button/image.png"
.to_string(),
asset_object_id: format!("{asset_id}-asset"),
generation_provider: "image2".to_string(),
prompt: "新的返回按钮".to_string(),
width: 1024,
height: 1024,
}
}
fn generated_hit_sound_asset(asset_id: &str) -> WoodenFishAudioAsset {
WoodenFishAudioAsset {
asset_id: asset_id.to_string(),
audio_src: "/generated-wooden-fish-assets/real-profile/hit-sound/sound.mp3".to_string(),
audio_object_key: "generated-wooden-fish-assets/real-profile/hit-sound/sound.mp3"
.to_string(),
asset_object_id: format!("{asset_id}-asset"),
source: "generated".to_string(),
prompt: Some("新的木鱼音效".to_string()),
duration_ms: Some(3000),
}
}
fn draft_with_assets() -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
profile_id: Some(PROFILE_ID.to_string()),
hit_object_asset: Some(WoodenFishImageAsset {
asset_id: "old-object".to_string(),
image_src: "/generated-wooden-fish-assets/old-object.png".to_string(),
image_object_key: "generated-wooden-fish-assets/old-object.png".to_string(),
asset_object_id: "old-object-asset".to_string(),
generation_provider: "image2".to_string(),
prompt: "旧敲击物".to_string(),
width: 1024,
height: 1024,
}),
background_asset: Some(WoodenFishImageAsset {
asset_id: "old-background".to_string(),
image_src: "/generated-wooden-fish-assets/old-background.png".to_string(),
image_object_key: "generated-wooden-fish-assets/old-background.png".to_string(),
asset_object_id: "old-background-asset".to_string(),
generation_provider: "image2".to_string(),
prompt: "旧背景".to_string(),
width: 1024,
height: 1536,
}),
back_button_asset: Some(WoodenFishImageAsset {
asset_id: "old-back".to_string(),
image_src: "/generated-wooden-fish-assets/old-back.png".to_string(),
image_object_key: "generated-wooden-fish-assets/old-back.png".to_string(),
asset_object_id: "old-back-asset".to_string(),
generation_provider: "image2".to_string(),
prompt: "旧返回按钮".to_string(),
width: 1024,
height: 1024,
}),
hit_sound_asset: Some(WoodenFishAudioAsset {
asset_id: "old-sound".to_string(),
audio_src: "/generated-wooden-fish-assets/old-sound.mp3".to_string(),
audio_object_key: "generated-wooden-fish-assets/old-sound.mp3".to_string(),
asset_object_id: "old-sound-asset".to_string(),
source: "generated".to_string(),
prompt: Some("旧音效".to_string()),
duration_ms: Some(700),
}),
cover_image_src: Some("/generated-wooden-fish-assets/old-object.png".to_string()),
generation_status: WoodenFishGenerationStatus::Ready,
..base_draft()
}
}
fn base_draft() -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: "旧标题".to_string(),
work_description: "旧描述".to_string(),
theme_tags: vec!["旧标签".to_string()],
hit_object_prompt: "旧敲击物".to_string(),
hit_object_reference_image_src: None,
hit_sound_prompt: None,
floating_words: default_floating_words(),
hit_object_asset: None,
background_asset: None,
back_button_asset: None,
hit_sound_asset: None,
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,
}
}
}