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

1106 lines
41 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 = "默认敲击物图案,圆润木质质感,透明背景";
const DEFAULT_HIT_SOUND_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,
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,
&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 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,
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,
&profile_id,
&mut draft,
WoodenFishAssetRefresh::Preserve,
now_micros,
)?)
}
WoodenFishActionType::RegenerateHitObject => {
WoodenFishActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
&profile_id,
&mut draft,
WoodenFishAssetRefresh::HitObject,
now_micros,
)?)
}
WoodenFishActionType::GenerateHitSound => {
WoodenFishActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
&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 matches!(
scope,
WoodenFishDraftMergeScope::CompileDraft
| WoodenFishDraftMergeScope::GenerateHitSound
| WoodenFishDraftMergeScope::ReplaceHitSound
) {
if let Some(value) = payload
.hit_sound_prompt
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.hit_sound_prompt = Some(value.trim().to_string());
}
}
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;
}
if draft.floating_words.is_empty() {
draft.floating_words = default_floating_words();
}
Ok(draft)
}
fn build_compile_input(
current: &WoodenFishSessionSnapshotResponse,
owner_user_id: &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 缺少真实生成资产")
})?;
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: "敲木鱼玩家".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)?),
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_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
},
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: Some(DEFAULT_HIT_SOUND_PROMPT.to_string()),
floating_words: default_floating_words(),
hit_object_asset: None,
background_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 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.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
let (plan, draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &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_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"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &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"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &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_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"));
let (plan, _draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &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")
);
}
#[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, &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("健康")
);
}
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,
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_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,
}),
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: Some("旧音效".to_string()),
floating_words: default_floating_words(),
hit_object_asset: None,
background_asset: None,
hit_sound_asset: None,
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,
}
}
}