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