feat: integrate jump-hop shelf and asset flow

This commit is contained in:
kdletters
2026-05-24 19:00:21 +08:00
parent 2ba4691bc0
commit 42037860d5
25 changed files with 1018 additions and 149 deletions

View File

@@ -226,8 +226,11 @@ impl SpacetimeClient {
&self,
profile_id: String,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
self.get_jump_hop_work_profile(profile_id, String::new())
.await
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(
@@ -235,12 +238,17 @@ impl SpacetimeClient {
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: payload.profile_id,
profile_id,
started_at_ms: current_unix_micros().div_euclid(1000),
};
self.start_jump_hop_run_with_input(procedure_input).await
@@ -372,11 +380,91 @@ impl SpacetimeClient {
&self,
public_work_code: String,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
self.get_jump_hop_work_profile(public_work_code, String::new())
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),
@@ -503,22 +591,61 @@ fn merge_action_into_draft(
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
) && let Some(value) = payload
.character_prompt
.as_ref()
.filter(|value| !value.trim().is_empty())
{
draft.character_prompt = value.trim().to_string();
) {
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
) && let Some(value) = payload
.tile_prompt
) {
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()
.filter(|value| !value.trim().is_empty())
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
draft.tile_prompt = value.trim().to_string();
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(
@@ -545,31 +672,30 @@ fn build_compile_input(
draft.tile_atlas_asset = None;
draft.tile_assets.clear();
}
let character_asset = ensure_character_asset(
draft.character_asset.clone(),
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,
&draft.character_prompt,
force_character,
refresh,
now_micros,
);
let tile_atlas_asset = ensure_tile_atlas_asset(
draft.tile_atlas_asset.clone(),
profile_id,
&draft.tile_prompt,
force_tiles,
now_micros,
);
let tile_assets = ensure_tile_assets(
draft.tile_assets.clone(),
profile_id,
force_tiles,
now_micros,
);
let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros);
draft.character_asset = Some(character_asset.clone());
draft.tile_atlas_asset = Some(tile_atlas_asset.clone());
draft.tile_assets = tile_assets.clone();
draft.cover_composite = cover_composite.clone();
draft.generation_status = JumpHopGenerationStatus::Ready;
@@ -698,8 +824,10 @@ fn ensure_character_asset(
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new && let Some(asset) = existing {
return asset;
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
@@ -722,8 +850,10 @@ fn ensure_tile_atlas_asset(
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new && let Some(asset) = existing {
return asset;
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
@@ -781,14 +911,15 @@ fn resolve_cover_composite(
refresh: JumpHopAssetRefresh,
now_micros: i64,
) -> Option<String> {
if matches!(refresh, JumpHopAssetRefresh::Preserve)
&& let Some(value) = draft
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());
{
return Some(value.to_string());
}
}
let suffix = asset_revision_suffix(
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),