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

1309 lines
47 KiB
Rust

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 delete_jump_hop_work(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<Vec<JumpHopWorkProfileResponse>, SpacetimeClientError> {
let procedure_input = JumpHopWorkDeleteInput {
profile_id,
owner_user_id,
};
self.call_after_connect("delete_jump_hop_work", move |connection, sender| {
connection.procedures().delete_jump_hop_work_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() < 18 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 需要 18 个地块资产",
));
}
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() < 18 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop compile-draft 需要 18 个真实地块资产,请先由 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: "跳一跳主题的3D立方体主题身份方块包装图集".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_18_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-18-object")
);
assert_eq!(draft.tile_assets.len(), 18);
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", 18));
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-18-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,
back_button_asset: 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", 18),
..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", 18),
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 / 3 + 1, index % 3 + 1),
atlas_row: Some(index as u32 / 3 + 1),
atlas_col: Some(index as u32 % 3 + 1),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
face_assets: None,
})
.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,
back_button_asset: 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,
},
}
}
}