1106 lines
41 KiB
Rust
1106 lines
41 KiB
Rust
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(
|
||
¤t,
|
||
&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,
|
||
}
|
||
}
|
||
}
|