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

1284 lines
45 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_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, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, 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)?;
Ok(work)
}
pub async fn start_jump_hop_run(
&self,
payload: JumpHopStartRunRequest,
owner_user_id: String,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
let profile_id = payload.profile_id;
let work = self
.get_jump_hop_work_profile(profile_id.clone(), String::new())
.await?;
validate_jump_hop_runtime_ready(&work)?;
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,
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,
charge_ms: payload.charge_ms,
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
}
}
fn validate_jump_hop_runtime_ready(
work: &JumpHopWorkProfileResponse,
) -> Result<(), SpacetimeClientError> {
let status = work.summary.publication_status.trim().to_ascii_lowercase();
if 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_character_asset_ready(&work.character_asset, "character_asset")?;
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
if work.tile_assets.is_empty() {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 缺少地块资产",
));
}
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 validate_jump_hop_character_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,
RegenerateCharacter,
RegenerateTiles,
UpdateWorkMeta,
UpdateDifficulty,
}
#[derive(Clone, Copy)]
enum JumpHopAssetRefresh {
Preserve,
Character,
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::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter,
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta,
JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty,
};
let mut draft = merge_action_into_draft(current.draft.clone(), 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::RegenerateCharacter => {
JumpHopActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
&profile_id,
&mut draft,
JumpHopAssetRefresh::Character,
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.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 | JumpHopDraftMergeScope::RegenerateCharacter
) {
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 | JumpHopDraftMergeScope::RegenerateCharacter
) {
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 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 force_character = matches!(refresh, JumpHopAssetRefresh::Character);
let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles);
if force_character {
draft.character_asset = None;
}
if force_tiles {
draft.tile_atlas_asset = None;
draft.tile_assets.clear();
}
let character_asset = draft.character_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
)
})?;
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.is_empty() {
return Err(SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实地块资产,请先由 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.work_title.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,
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,
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,
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 build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeClientError> {
serde_json::to_string(&serde_json::json!({
"themeText": draft.work_title,
"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 ensure_character_asset(
existing: Option<JumpHopCharacterAsset>,
profile_id: &str,
prompt: &str,
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
JumpHopCharacterAsset {
asset_id: format!("{profile_id}-character{suffix}"),
image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
asset_object_id: format!("{profile_id}-character{suffix}-object"),
generation_provider: "deterministic-placeholder".to_string(),
prompt: prompt.to_string(),
width: 768,
height: 768,
}
}
fn ensure_tile_atlas_asset(
existing: Option<JumpHopCharacterAsset>,
profile_id: &str,
prompt: &str,
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
JumpHopCharacterAsset {
asset_id: format!("{profile_id}-tile-atlas{suffix}"),
image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"),
generation_provider: "deterministic-placeholder".to_string(),
prompt: prompt.to_string(),
width: 1024,
height: 1024,
}
}
fn ensure_tile_assets(
existing: Vec<JumpHopTileAsset>,
profile_id: &str,
force_new: bool,
now_micros: i64,
) -> Vec<JumpHopTileAsset> {
if !force_new && !existing.is_empty() {
return existing;
}
let suffix = asset_revision_suffix(force_new.then_some(now_micros));
[
JumpHopTileType::Start,
JumpHopTileType::Normal,
JumpHopTileType::Target,
JumpHopTileType::Finish,
JumpHopTileType::Bonus,
JumpHopTileType::Accent,
]
.into_iter()
.enumerate()
.map(|(index, tile_type)| JumpHopTileAsset {
tile_type,
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"),
image_object_key: format!(
"generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"
),
asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"),
source_atlas_cell: format!("cell-{index}{suffix}"),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
})
.collect()
}
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 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",
}
}
#[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_assets() {
let session = session_with_draft(draft_without_assets());
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("-character")
);
assert!(
input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
.contains("-tile-atlas")
);
assert!(
input
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("tile-0-object")
);
assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready);
}
#[test]
fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(JumpHopActionType::RegenerateCharacter);
payload.character_prompt = Some("新的主角提示词".to_string());
let (plan, _draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("regenerate-character should build plan");
let JumpHopActionProcedure::Compile(input) = plan else {
panic!("regenerate-character should call compile_jump_hop_draft");
};
assert!(
!input
.character_asset_json
.as_deref()
.unwrap_or("")
.contains("old-character")
);
assert!(
input
.character_asset_json
.as_deref()
.unwrap_or("")
.contains(&NOW_MICROS.to_string())
);
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-normal-tile")
);
}
#[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());
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("old-character")
);
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-normal-tile")
);
assert!(
input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
.contains(&NOW_MICROS.to_string())
);
assert!(
input
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains(&NOW_MICROS.to_string())
);
}
#[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("old-character")
);
assert_eq!(
draft
.tile_assets
.first()
.map(|asset| asset.asset_object_id.as_str()),
Some("old-normal-tile-object")
);
}
/// 构造不携带资产覆盖的 JumpHop action单测按需再覆盖字段。
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
JumpHopActionRequest {
action_type,
profile_id: 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_assets() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: None,
..base_draft()
}
}
fn draft_with_assets() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: Some(PROFILE_ID.to_string()),
character_asset: Some(JumpHopCharacterAsset {
asset_id: "old-character".to_string(),
image_src: "/generated-jump-hop-assets/old-character.png".to_string(),
image_object_key: "generated-jump-hop-assets/old-character.png".to_string(),
asset_object_id: "old-character-object".to_string(),
generation_provider: "old-provider".to_string(),
prompt: "旧角色提示词".to_string(),
width: 768,
height: 768,
}),
tile_atlas_asset: Some(JumpHopCharacterAsset {
asset_id: "old-tile-atlas".to_string(),
image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(),
image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(),
asset_object_id: "old-tile-atlas-object".to_string(),
generation_provider: "old-provider".to_string(),
prompt: "旧地块提示词".to_string(),
width: 1024,
height: 1024,
}),
tile_assets: vec![JumpHopTileAsset {
tile_type: JumpHopTileType::Normal,
image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(),
image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(),
asset_object_id: "old-normal-tile-object".to_string(),
source_atlas_cell: "old-cell".to_string(),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
}],
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 base_draft() -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: "旧标题".to_string(),
work_description: "旧描述".to_string(),
theme_tags: vec!["旧标签".to_string()],
difficulty: JumpHopDifficulty::Standard,
style_preset: JumpHopStylePreset::MinimalBlocks,
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,
},
}
}
}