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

1282 lines
46 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_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row,
map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result,
map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result,
};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
JumpHopJumpRequest, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
JumpHopStylePreset, JumpHopWorkProfileResponse,
};
use shared_kernel::build_prefixed_uuid_id;
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
impl SpacetimeClient {
pub async fn create_jump_hop_session(
&self,
session: JumpHopSessionSnapshotResponse,
) -> Result<JumpHopSessionSnapshotResponse, SpacetimeClientError> {
let draft = session.draft.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("jump-hop session 缺少 draft")
})?;
let theme_tags_json = Some(json_string(&draft.theme_tags)?);
let config_json = Some(build_config_json(&draft)?);
let work_title = draft.work_title.clone();
let work_description = draft.work_description.clone();
let procedure_input = JumpHopAgentSessionCreateInput {
session_id: session.session_id,
owner_user_id: session.owner_user_id,
seed_text: work_title.clone(),
work_title,
work_description,
theme_tags_json,
welcome_message_text: "跳一跳草稿已准备好。".to_string(),
config_json,
created_at_micros: current_unix_micros(),
};
self.call_after_connect(
"create_jump_hop_agent_session",
move |connection, sender| {
connection.procedures().create_jump_hop_agent_session_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn get_jump_hop_session(
&self,
session_id: String,
owner_user_id: String,
) -> Result<JumpHopSessionSnapshotResponse, SpacetimeClientError> {
let procedure_input = JumpHopAgentSessionGetInput {
session_id,
owner_user_id,
};
self.call_after_connect("get_jump_hop_agent_session", move |connection, sender| {
connection.procedures().get_jump_hop_agent_session_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn execute_jump_hop_action(
&self,
session_id: String,
owner_user_id: String,
payload: JumpHopActionRequest,
) -> Result<JumpHopActionResponse, SpacetimeClientError> {
let current = self
.get_jump_hop_session(session_id.clone(), owner_user_id.clone())
.await?;
let (procedure, _) =
build_jump_hop_action_plan(&current, &owner_user_id, &payload, current_unix_micros())?;
let (session, work) = match procedure {
JumpHopActionProcedure::Compile(input) => {
let profile_id = input.profile_id.clone();
let session = self.compile_jump_hop_draft(input).await?;
let work = self
.get_jump_hop_work_profile(profile_id, owner_user_id)
.await
.ok();
(session, work)
}
JumpHopActionProcedure::Update(input) => {
let work = self.update_jump_hop_work(input).await?;
let session = apply_jump_hop_work_to_session(current, &work);
(session, Some(work))
}
};
Ok(JumpHopActionResponse {
action_type: payload.action_type,
session,
work,
})
}
pub async fn compile_jump_hop_draft(
&self,
procedure_input: JumpHopDraftCompileInput,
) -> Result<JumpHopSessionSnapshotResponse, SpacetimeClientError> {
self.call_after_connect("compile_jump_hop_draft", move |connection, sender| {
connection.procedures().compile_jump_hop_draft_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn get_jump_hop_work_profile(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
let procedure_input = JumpHopWorkGetInput {
profile_id,
owner_user_id,
};
self.call_after_connect("get_jump_hop_work_profile", move |connection, sender| {
connection.procedures().get_jump_hop_work_profile_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn update_jump_hop_work(
&self,
procedure_input: JumpHopWorkUpdateInput,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
self.call_after_connect("update_jump_hop_work", move |connection, sender| {
connection
.procedures()
.update_jump_hop_work_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_work_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn publish_jump_hop_work(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
let procedure_input = JumpHopWorkPublishInput {
profile_id,
owner_user_id,
published_at_micros: current_unix_micros(),
};
self.call_after_connect("publish_jump_hop_work", move |connection, sender| {
connection.procedures().publish_jump_hop_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn list_jump_hop_works(
&self,
owner_user_id: String,
) -> Result<Vec<JumpHopWorkProfileResponse>, SpacetimeClientError> {
let procedure_input = JumpHopWorksListInput {
owner_user_id,
published_only: false,
};
self.call_after_connect("list_jump_hop_works", move |connection, sender| {
connection
.procedures()
.list_jump_hop_works_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_works_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_jump_hop_runtime_work(
&self,
profile_id: String,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
let work = self
.get_jump_hop_work_profile(profile_id, String::new())
.await?;
validate_jump_hop_runtime_ready(&work, "published")?;
Ok(work)
}
pub async fn start_jump_hop_run(
&self,
payload: JumpHopStartRunRequest,
owner_user_id: String,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref());
let profile_id = payload.profile_id;
let work_owner_user_id = if runtime_mode == "draft" {
owner_user_id.clone()
} else {
String::new()
};
let work = self
.get_jump_hop_work_profile(profile_id.clone(), work_owner_user_id)
.await?;
validate_jump_hop_runtime_ready(&work, runtime_mode)?;
let run_id = build_prefixed_uuid_id("jump-hop-run-");
let procedure_input = JumpHopRunStartInput {
client_event_id: format!("{run_id}:start"),
run_id,
owner_user_id,
profile_id,
runtime_mode: runtime_mode.to_string(),
started_at_ms: current_unix_micros().div_euclid(1000),
};
self.start_jump_hop_run_with_input(procedure_input).await
}
pub async fn start_jump_hop_run_with_input(
&self,
procedure_input: JumpHopRunStartInput,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
self.call_after_connect("start_jump_hop_run", move |connection, sender| {
connection
.procedures()
.start_jump_hop_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_run_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_jump_hop_run(
&self,
run_id: String,
owner_user_id: String,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
let procedure_input = JumpHopRunGetInput {
run_id,
owner_user_id,
};
self.call_after_connect("get_jump_hop_run", move |connection, sender| {
connection
.procedures()
.get_jump_hop_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_run_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn jump_hop_run_jump(
&self,
run_id: String,
owner_user_id: String,
payload: JumpHopJumpRequest,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
let procedure_input = JumpHopRunJumpInput {
run_id,
owner_user_id,
drag_distance: payload.drag_distance,
drag_vector_x: payload.drag_vector_x,
drag_vector_y: payload.drag_vector_y,
client_event_id: payload.client_event_id,
jumped_at_ms: current_unix_micros().div_euclid(1000),
};
self.call_after_connect("jump_hop_jump", move |connection, sender| {
connection
.procedures()
.jump_hop_jump_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_run_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn restart_jump_hop_run(
&self,
run_id: String,
owner_user_id: String,
payload: JumpHopRestartRunRequest,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
let procedure_input = JumpHopRunRestartInput {
source_run_id: run_id,
next_run_id: build_prefixed_uuid_id("jump-hop-run-"),
owner_user_id,
client_action_id: payload.client_action_id,
restarted_at_ms: current_unix_micros().div_euclid(1000),
};
self.call_after_connect("restart_jump_hop_run", move |connection, sender| {
connection
.procedures()
.restart_jump_hop_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_run_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn list_jump_hop_gallery(
&self,
) -> Result<JumpHopGalleryResponse, SpacetimeClientError> {
self.read_after_connect("list_jump_hop_gallery", move |connection| {
let mut items = connection
.db()
.jump_hop_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(JumpHopGalleryResponse {
items: items
.into_iter()
.map(map_jump_hop_gallery_card_view_row)
.collect(),
has_more: false,
next_cursor: None,
})
})
.await
}
pub async fn get_jump_hop_gallery_detail(
&self,
public_work_code: String,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
let gallery = self.list_jump_hop_gallery().await?;
let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str());
let card = gallery
.items
.into_iter()
.find(|item| {
normalize_jump_hop_public_work_code(item.public_work_code.as_str())
== requested_code
})
.ok_or_else(|| {
SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string())
})?;
self.get_jump_hop_work_profile(card.profile_id, String::new())
.await
}
pub async fn get_jump_hop_leaderboard(
&self,
profile_id: String,
viewer_player_id: String,
) -> Result<JumpHopLeaderboardResponse, SpacetimeClientError> {
let procedure_input = JumpHopLeaderboardGetInput {
profile_id,
viewer_player_id,
limit: 50,
};
self.call_after_connect("get_jump_hop_leaderboard", move |connection, sender| {
connection.procedures().get_jump_hop_leaderboard_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_jump_hop_leaderboard_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
}
fn validate_jump_hop_runtime_ready(
work: &JumpHopWorkProfileResponse,
runtime_mode: &str,
) -> Result<(), SpacetimeClientError> {
let status = work.summary.publication_status.trim().to_ascii_lowercase();
if runtime_mode == "published" && status != "published" {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 只能启动已发布作品",
));
}
if work.summary.generation_status != JumpHopGenerationStatus::Ready {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 需要 ready 状态作品",
));
}
validate_jump_hop_default_character_ready(work)?;
validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
if work.tile_assets.len() < 25 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 需要 25 个地块资产",
));
}
for (index, asset) in work.tile_assets.iter().enumerate() {
if asset.image_src.trim().is_empty()
|| asset.image_object_key.trim().is_empty()
|| asset.asset_object_id.trim().is_empty()
{
return Err(SpacetimeClientError::validation_failed(format!(
"jump-hop runtime 地块资产 #{index} 不完整"
)));
}
}
if work.path.platforms.is_empty() {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 缺少可玩路径",
));
}
Ok(())
}
fn normalize_jump_hop_runtime_mode(value: Option<&str>) -> &'static str {
if value
.map(|value| value.trim().eq_ignore_ascii_case("draft"))
.unwrap_or(false)
{
"draft"
} else {
"published"
}
}
fn validate_jump_hop_default_character_ready(
work: &JumpHopWorkProfileResponse,
) -> Result<(), SpacetimeClientError> {
let Some(default_character) = work.default_character.as_ref() else {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 缺少内置默认角色配置",
));
};
if default_character.model_kind.trim() != "builtin-three" {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 默认角色必须使用 builtin-three",
));
}
Ok(())
}
fn validate_jump_hop_tile_atlas_asset_ready(
asset: &JumpHopCharacterAsset,
field: &str,
) -> Result<(), SpacetimeClientError> {
if asset.image_src.trim().is_empty()
|| asset.image_object_key.trim().is_empty()
|| asset.asset_object_id.trim().is_empty()
{
return Err(SpacetimeClientError::validation_failed(format!(
"jump-hop runtime {field} 不完整"
)));
}
if asset.generation_provider.trim().is_empty()
|| asset.generation_provider == "deterministic-placeholder"
{
return Err(SpacetimeClientError::validation_failed(format!(
"jump-hop runtime {field} 不是可用真实生成资产"
)));
}
Ok(())
}
fn normalize_jump_hop_public_work_code(value: &str) -> String {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.map(|character| character.to_ascii_uppercase())
.collect()
}
enum JumpHopActionProcedure {
Compile(JumpHopDraftCompileInput),
Update(JumpHopWorkUpdateInput),
}
#[derive(Clone, Copy)]
enum JumpHopDraftMergeScope {
CompileDraft,
RegenerateTiles,
UpdateWorkMeta,
UpdateDifficulty,
}
#[derive(Clone, Copy)]
enum JumpHopAssetRefresh {
Preserve,
Tiles,
}
fn build_jump_hop_action_plan(
current: &JumpHopSessionSnapshotResponse,
owner_user_id: &str,
payload: &JumpHopActionRequest,
now_micros: i64,
) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> {
let scope = match payload.action_type {
JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft,
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta,
JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty,
};
let mut base_draft = current.draft.clone();
if matches!(payload.action_type, JumpHopActionType::RegenerateTiles) {
if let Some(draft) = base_draft.as_mut() {
draft.tile_atlas_asset = None;
draft.tile_assets.clear();
}
}
let mut draft = merge_action_into_draft(base_draft, payload, scope)?;
let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?;
draft.profile_id = Some(profile_id.clone());
let procedure = match payload.action_type {
JumpHopActionType::CompileDraft => JumpHopActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
&profile_id,
&mut draft,
JumpHopAssetRefresh::Preserve,
now_micros,
)?),
JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
&profile_id,
&mut draft,
JumpHopAssetRefresh::Tiles,
now_micros,
)?),
JumpHopActionType::UpdateWorkMeta | JumpHopActionType::UpdateDifficulty => {
JumpHopActionProcedure::Update(build_update_input(
owner_user_id,
&profile_id,
&draft,
&payload.action_type,
now_micros,
)?)
}
};
Ok((procedure, draft))
}
fn merge_action_into_draft(
draft: Option<JumpHopDraftResponse>,
payload: &JumpHopActionRequest,
scope: JumpHopDraftMergeScope,
) -> Result<JumpHopDraftResponse, SpacetimeClientError> {
let mut draft = draft.unwrap_or_else(default_draft);
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::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
.theme_text
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.theme_text = value.trim().chars().take(60).collect();
}
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_jump_hop_tags(value);
}
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::UpdateDifficulty
) {
if let Some(value) = payload.difficulty.clone() {
draft.difficulty = value;
}
}
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
if let Some(value) = payload.style_preset.clone() {
draft.style_preset = value;
}
if payload.end_mood_prompt.is_some() {
draft.end_mood_prompt = payload
.end_mood_prompt
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
}
}
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
if let Some(value) = payload
.character_prompt
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.character_prompt = value.trim().to_string();
}
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
) {
if let Some(value) = payload
.tile_prompt
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.tile_prompt = value.trim().to_string();
}
}
if let Some(profile_id) = payload
.profile_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
draft.profile_id = Some(profile_id.to_string());
}
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
if let Some(asset) = payload.character_asset.clone() {
draft.character_asset = Some(asset);
}
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
) {
if let Some(asset) = payload.tile_atlas_asset.clone() {
draft.tile_atlas_asset = Some(asset);
}
if let Some(assets) = payload.tile_assets.clone() {
draft.tile_assets = assets;
}
}
if let Some(value) = payload
.cover_composite
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
draft.cover_composite = Some(value.to_string());
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
) {
if let Some(asset) = payload.back_button_asset.clone() {
draft.back_button_asset = Some(asset);
}
}
if draft.work_title.trim().is_empty() {
return Err(SpacetimeClientError::validation_failed(
"jump-hop work_title 不能为空",
));
}
Ok(draft)
}
fn build_compile_input(
current: &JumpHopSessionSnapshotResponse,
owner_user_id: &str,
profile_id: &str,
draft: &mut JumpHopDraftResponse,
refresh: JumpHopAssetRefresh,
now_micros: i64,
) -> Result<JumpHopDraftCompileInput, SpacetimeClientError> {
let character_asset = draft.character_asset.clone().unwrap_or_else(|| {
build_jump_hop_default_character_asset(profile_id, draft.theme_text.as_str())
});
draft.character_asset = Some(character_asset.clone());
draft.default_character = Some(default_jump_hop_default_character());
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
)
})?;
let tile_assets = if draft.tile_assets.len() < 25 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object",
));
} else {
draft.tile_assets.clone()
};
let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros);
draft.cover_composite = cover_composite.clone();
draft.generation_status = JumpHopGenerationStatus::Ready;
Ok(JumpHopDraftCompileInput {
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(),
seed_text: draft.work_title.clone(),
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
theme_text: Some(draft.theme_text.clone()),
difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()),
style_preset: Some(style_to_str(&draft.style_preset).to_string()),
character_prompt: Some(draft.character_prompt.clone()),
tile_prompt: Some(draft.tile_prompt.clone()),
end_mood_prompt: draft.end_mood_prompt.clone(),
character_asset_json: Some(json_string(&character_asset)?),
tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?),
tile_assets_json: Some(json_string(&tile_assets)?),
cover_composite,
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
generation_status: Some("ready".to_string()),
compiled_at_micros: now_micros,
})
}
fn build_update_input(
owner_user_id: &str,
profile_id: &str,
draft: &JumpHopDraftResponse,
action_type: &JumpHopActionType,
now_micros: i64,
) -> Result<JumpHopWorkUpdateInput, SpacetimeClientError> {
Ok(JumpHopWorkUpdateInput {
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)?,
difficulty: matches!(action_type, JumpHopActionType::UpdateDifficulty)
.then(|| difficulty_to_str(&draft.difficulty).to_string()),
style_preset: None,
cover_image_src: None,
cover_composite: None,
updated_at_micros: now_micros,
})
}
fn resolve_jump_hop_profile_id(
draft: &JumpHopDraftResponse,
action_type: &JumpHopActionType,
) -> Result<String, SpacetimeClientError> {
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, JumpHopActionType::CompileDraft) {
return Ok(build_prefixed_uuid_id("jump-hop-profile-"));
}
Err(SpacetimeClientError::validation_failed(
"jump-hop action 需要先完成 compile-draft",
))
}
fn apply_jump_hop_work_to_session(
mut session: JumpHopSessionSnapshotResponse,
work: &JumpHopWorkProfileResponse,
) -> JumpHopSessionSnapshotResponse {
session.status = work.draft.generation_status.clone();
session.draft = Some(work.draft.clone());
session.updated_at = work.summary.updated_at.clone();
session
}
fn normalize_jump_hop_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_draft() -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
theme_text: JUMP_HOP_TEMPLATE_NAME.to_string(),
work_title: JUMP_HOP_TEMPLATE_NAME.to_string(),
work_description: "俯视角跳跃闯关".to_string(),
theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()],
difficulty: JumpHopDifficulty::Standard,
style_preset: JumpHopStylePreset::MinimalBlocks,
default_character: Some(default_jump_hop_default_character()),
character_prompt: "内置默认 3D 角色".to_string(),
tile_prompt: "跳一跳主题的正面30度视角主题物体图集物体本身作为跳跃落点".to_string(),
end_mood_prompt: None,
character_asset: None,
tile_atlas_asset: None,
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeClientError> {
serde_json::to_string(&serde_json::json!({
"themeText": draft.theme_text,
"difficulty": difficulty_to_str(&draft.difficulty),
"stylePreset": style_to_str(&draft.style_preset),
"characterPrompt": draft.character_prompt,
"tilePrompt": draft.tile_prompt,
"endMoodPrompt": draft.end_mood_prompt.clone().unwrap_or_default(),
}))
.map_err(SpacetimeClientError::validation_failed)
}
fn resolve_cover_composite(
draft: &JumpHopDraftResponse,
profile_id: &str,
refresh: JumpHopAssetRefresh,
now_micros: i64,
) -> Option<String> {
if matches!(refresh, JumpHopAssetRefresh::Preserve) {
if let Some(value) = draft
.cover_composite
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
}
let suffix = asset_revision_suffix(
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),
);
Some(format!(
"/generated-jump-hop-assets/{profile_id}/cover-composite{suffix}.png"
))
}
fn build_jump_hop_default_character_asset(
profile_id: &str,
theme_text: &str,
) -> JumpHopCharacterAsset {
JumpHopCharacterAsset {
asset_id: format!("{profile_id}-builtin-character"),
image_src: "builtin://jump-hop/default-character".to_string(),
image_object_key: String::new(),
asset_object_id: format!("{profile_id}-builtin-character"),
generation_provider: "builtin-three".to_string(),
prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
width: 0,
height: 0,
}
}
fn asset_revision_suffix(revision: Option<i64>) -> String {
revision
.filter(|value| *value > 0)
.map(|value| format!("-{value}"))
.unwrap_or_default()
}
fn json_string<T: serde::Serialize>(value: &T) -> Result<String, SpacetimeClientError> {
serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed)
}
fn difficulty_to_str(value: &JumpHopDifficulty) -> &'static str {
match value {
JumpHopDifficulty::Easy => "easy",
JumpHopDifficulty::Standard => "standard",
JumpHopDifficulty::Advanced => "advanced",
JumpHopDifficulty::Challenge => "challenge",
}
}
fn style_to_str(value: &JumpHopStylePreset) -> &'static str {
match value {
JumpHopStylePreset::MinimalBlocks => "minimal-blocks",
JumpHopStylePreset::PaperToy => "paper-toy",
JumpHopStylePreset::NeonGlass => "neon-glass",
JumpHopStylePreset::ForestStone => "forest-stone",
JumpHopStylePreset::FutureMetal => "future-metal",
JumpHopStylePreset::Custom => "custom",
}
}
fn default_jump_hop_default_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter {
shared_contracts::jump_hop::JumpHopDefaultCharacter {
character_id: "jump-hop-default-runner".to_string(),
display_name: "默认角色".to_string(),
model_kind: "builtin-three".to_string(),
body_color: "#f59e0b".to_string(),
accent_color: "#2563eb".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::jump_hop::JumpHopActionType;
const SESSION_ID: &str = "jump-hop-session-test";
const OWNER_USER_ID: &str = "user-test";
const PROFILE_ID: &str = "jump-hop-profile-test";
const NOW_MICROS: i64 = 1_763_456_789_000_000;
#[test]
fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character()
{
let session = session_with_draft(draft_without_character_asset());
let payload = action(JumpHopActionType::CompileDraft);
let (plan, draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("compile-draft should build plan");
let JumpHopActionProcedure::Compile(input) = plan else {
panic!("compile-draft should call compile_jump_hop_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
.character_asset_json
.as_deref()
.unwrap_or("")
.contains("builtin-three")
);
assert!(
input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
.contains("-tile-atlas")
);
assert!(
input
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("old-tile-25-object")
);
assert_eq!(draft.tile_assets.len(), 25);
assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready);
}
#[test]
fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(JumpHopActionType::RegenerateTiles);
payload.tile_prompt = Some("新的地块提示词".to_string());
payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS));
payload.tile_assets = Some(tile_assets("new", 25));
let (plan, _draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("regenerate-tiles should build plan");
let JumpHopActionProcedure::Compile(input) = plan else {
panic!("regenerate-tiles should call compile_jump_hop_draft");
};
assert!(
input
.character_asset_json
.as_deref()
.unwrap_or("")
.contains("builtin-three")
);
assert!(
!input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
.contains("old-tile-atlas")
);
assert!(
!input
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("old-tile-01-object")
);
assert!(
input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
.contains("new-tile-atlas")
);
assert!(
input
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("new-tile-25-object")
);
}
#[test]
fn jump_hop_action_compile_draft_persists_theme_text_separately_from_title() {
let session = session_with_draft(draft_without_character_asset());
let mut payload = action(JumpHopActionType::CompileDraft);
payload.theme_text = Some(" 森林蘑菇跳台 ".to_string());
payload.work_title = Some("自动标题".to_string());
let (plan, draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("compile-draft should build plan");
let JumpHopActionProcedure::Compile(input) = plan else {
panic!("compile-draft should call compile_jump_hop_draft");
};
assert_eq!(draft.theme_text, "森林蘑菇跳台");
assert_eq!(input.theme_text.as_deref(), Some("森林蘑菇跳台"));
assert_eq!(input.work_title, "自动标题");
}
#[test]
fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(JumpHopActionType::UpdateWorkMeta);
payload.work_title = Some("新标题".to_string());
payload.work_description = Some("新描述".to_string());
payload.theme_tags = Some(vec![" A ".to_string(), "B".to_string()]);
payload.character_prompt = Some("不应影响角色资产".to_string());
let (plan, draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("update-work-meta should build plan");
let JumpHopActionProcedure::Update(input) = plan else {
panic!("update-work-meta should call update_jump_hop_work");
};
assert_eq!(input.profile_id, PROFILE_ID);
assert_eq!(input.work_title, "新标题");
assert_eq!(input.work_description, "新描述");
assert!(input.difficulty.is_none());
assert!(input.style_preset.is_none());
assert_eq!(draft.character_prompt, "旧角色提示词");
}
#[test]
fn jump_hop_action_update_difficulty_builds_update_input_without_asset_compile() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(JumpHopActionType::UpdateDifficulty);
payload.difficulty = Some(JumpHopDifficulty::Challenge);
let (plan, draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("update-difficulty should build plan");
let JumpHopActionProcedure::Update(input) = plan else {
panic!("update-difficulty should call update_jump_hop_work");
};
assert_eq!(input.difficulty.as_deref(), Some("challenge"));
assert!(input.style_preset.is_none());
assert_eq!(
draft
.character_asset
.as_ref()
.map(|asset| asset.asset_id.as_str()),
Some("jump-hop-profile-test-builtin-character")
);
assert_eq!(
draft
.tile_assets
.first()
.map(|asset| asset.asset_object_id.as_str()),
Some("old-tile-01-object")
);
}
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
JumpHopActionRequest {
action_type,
profile_id: None,
theme_text: None,
work_title: None,
work_description: None,
theme_tags: None,
difficulty: None,
style_preset: None,
character_prompt: None,
tile_prompt: None,
end_mood_prompt: None,
character_asset: None,
tile_atlas_asset: None,
tile_assets: None,
cover_composite: None,
}
}
fn session_with_draft(draft: JumpHopDraftResponse) -> JumpHopSessionSnapshotResponse {
JumpHopSessionSnapshotResponse {
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-19T00:00:00Z".to_string(),
updated_at: "2026-05-19T00:00:00Z".to_string(),
}
}
fn draft_without_character_asset() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: None,
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
tile_assets: tile_assets("old", 25),
..base_draft()
}
}
fn draft_with_assets() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: Some(PROFILE_ID.to_string()),
character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")),
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
tile_assets: tile_assets("old", 25),
path: Some(sample_jump_hop_path()),
cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()),
generation_status: JumpHopGenerationStatus::Ready,
..base_draft()
}
}
fn tile_atlas_asset(asset_id: &str, revision: i64) -> JumpHopCharacterAsset {
let suffix = asset_revision_suffix((revision > 0).then_some(revision));
JumpHopCharacterAsset {
asset_id: asset_id.to_string(),
image_src: format!("/generated-jump-hop-assets/{asset_id}{suffix}.png"),
image_object_key: format!("generated-jump-hop-assets/{asset_id}{suffix}.png"),
asset_object_id: format!("{asset_id}-object"),
generation_provider: "vector-engine-image2".to_string(),
prompt: "旧地块提示词".to_string(),
width: 1024,
height: 1024,
}
}
fn tile_assets(prefix: &str, count: usize) -> Vec<JumpHopTileAsset> {
(0..count)
.map(|index| JumpHopTileAsset {
tile_type: if index == 0 {
JumpHopTileType::Start
} else {
JumpHopTileType::Normal
},
tile_id: Some(format!("tile-{:02}", index + 1)),
image_src: format!("/generated-jump-hop-assets/{prefix}-tile-{}.png", index + 1),
image_object_key: format!(
"generated-jump-hop-assets/{prefix}-tile-{}.png",
index + 1
),
asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1),
source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1),
atlas_row: Some(index as u32 / 5 + 1),
atlas_col: Some(index as u32 % 5 + 1),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
})
.collect()
}
fn base_draft() -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
theme_text: "旧主题".to_string(),
work_title: "旧标题".to_string(),
work_description: "旧描述".to_string(),
theme_tags: vec!["旧标签".to_string()],
difficulty: JumpHopDifficulty::Standard,
style_preset: JumpHopStylePreset::MinimalBlocks,
default_character: Some(default_jump_hop_default_character()),
character_prompt: "旧角色提示词".to_string(),
tile_prompt: "旧地块提示词".to_string(),
end_mood_prompt: None,
character_asset: None,
tile_atlas_asset: None,
tile_assets: Vec::new(),
path: None,
cover_composite: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
fn sample_jump_hop_path() -> JumpHopPath {
JumpHopPath {
seed: "jump-hop-test".to_string(),
difficulty: JumpHopDifficulty::Standard,
platforms: vec![JumpHopPlatform {
platform_id: "platform-0".to_string(),
tile_type: JumpHopTileType::Start,
x: 0.0,
y: 0.0,
width: 92.0,
height: 70.0,
landing_radius: 34.0,
perfect_radius: 14.0,
score_value: 10,
}],
finish_index: 0,
camera_preset: "portrait-isometric-follow".to_string(),
scoring: JumpHopScoring {
charge_to_distance_ratio: 0.018,
max_charge_ms: 1_200,
hit_bonus: 10,
perfect_bonus: 20,
},
}
}
}