1022 lines
36 KiB
Rust
1022 lines
36 KiB
Rust
use super::*;
|
|
use crate::mapper::{
|
|
map_puzzle_clear_agent_session_procedure_result, map_puzzle_clear_gallery_card_view_row,
|
|
map_puzzle_clear_run_procedure_result, map_puzzle_clear_work_procedure_result,
|
|
map_puzzle_clear_works_procedure_result,
|
|
};
|
|
use module_puzzle_clear::{PUZZLE_CLEAR_PROFILE_ID_PREFIX, PUZZLE_CLEAR_RUN_ID_PREFIX};
|
|
use shared_contracts::puzzle_clear::{
|
|
PuzzleClearActionRequest, PuzzleClearActionResponse, PuzzleClearActionType,
|
|
PuzzleClearGenerationStatus, PuzzleClearImageAsset, PuzzleClearNextLevelRequest,
|
|
PuzzleClearPatternGroup, PuzzleClearRetryLevelRequest, PuzzleClearRuntimeSnapshotResponse,
|
|
PuzzleClearSessionSnapshotResponse, PuzzleClearStartRunRequest, PuzzleClearSwapRequest,
|
|
PuzzleClearTimeUpRequest, PuzzleClearWorkProfileResponse, PuzzleClearWorkSummaryResponse,
|
|
};
|
|
use shared_kernel::build_prefixed_uuid_id;
|
|
|
|
const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear";
|
|
const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消";
|
|
const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 128;
|
|
const PUZZLE_CLEAR_ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
|
|
|
|
impl SpacetimeClient {
|
|
pub async fn create_puzzle_clear_session(
|
|
&self,
|
|
session: PuzzleClearSessionSnapshotResponse,
|
|
) -> Result<PuzzleClearSessionSnapshotResponse, SpacetimeClientError> {
|
|
let draft = session.draft.clone().ok_or_else(|| {
|
|
SpacetimeClientError::validation_failed("puzzle-clear session 缺少 draft")
|
|
})?;
|
|
let procedure_input = PuzzleClearAgentSessionCreateInput {
|
|
session_id: session.session_id,
|
|
owner_user_id: session.owner_user_id,
|
|
work_title: draft.work_title,
|
|
work_description: draft.work_description,
|
|
theme_prompt: draft.theme_prompt,
|
|
generate_board_background: draft.generate_board_background,
|
|
board_background_asset_json: draft
|
|
.board_background_asset
|
|
.as_ref()
|
|
.map(json_string)
|
|
.transpose()?,
|
|
board_background_prompt: draft.board_background_prompt,
|
|
created_at_micros: current_unix_micros(),
|
|
};
|
|
|
|
self.call_after_connect(
|
|
"create_puzzle_clear_agent_session",
|
|
move |connection, sender| {
|
|
connection
|
|
.procedures()
|
|
.create_puzzle_clear_agent_session_then(procedure_input, move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_agent_session_procedure_result);
|
|
send_once(&sender, mapped);
|
|
});
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_puzzle_clear_session(
|
|
&self,
|
|
session_id: String,
|
|
owner_user_id: String,
|
|
) -> Result<PuzzleClearSessionSnapshotResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearAgentSessionGetInput {
|
|
session_id,
|
|
owner_user_id,
|
|
};
|
|
|
|
self.call_after_connect(
|
|
"get_puzzle_clear_agent_session",
|
|
move |connection, sender| {
|
|
connection.procedures().get_puzzle_clear_agent_session_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_agent_session_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn execute_puzzle_clear_action(
|
|
&self,
|
|
session_id: String,
|
|
owner_user_id: String,
|
|
author_display_name: String,
|
|
payload: PuzzleClearActionRequest,
|
|
) -> Result<PuzzleClearActionResponse, SpacetimeClientError> {
|
|
let current = self
|
|
.get_puzzle_clear_session(session_id.clone(), owner_user_id.clone())
|
|
.await?;
|
|
let (procedure, _) = build_puzzle_clear_action_plan(
|
|
¤t,
|
|
&owner_user_id,
|
|
&author_display_name,
|
|
&payload,
|
|
current_unix_micros(),
|
|
)?;
|
|
let (session, work) = match procedure {
|
|
PuzzleClearActionProcedure::Compile(input) => {
|
|
let profile_id = input.profile_id.clone();
|
|
let session = self.compile_puzzle_clear_draft(input).await?;
|
|
let work = self
|
|
.get_puzzle_clear_work_profile(profile_id, owner_user_id)
|
|
.await
|
|
.ok();
|
|
(session, work)
|
|
}
|
|
PuzzleClearActionProcedure::Update(input) => {
|
|
let work = self.update_puzzle_clear_work(input).await?;
|
|
let session = apply_puzzle_clear_work_to_session(current, &work);
|
|
(session, Some(work))
|
|
}
|
|
};
|
|
|
|
Ok(PuzzleClearActionResponse {
|
|
action_type: payload.action_type,
|
|
session,
|
|
work,
|
|
})
|
|
}
|
|
|
|
pub async fn compile_puzzle_clear_draft(
|
|
&self,
|
|
procedure_input: PuzzleClearDraftCompileInput,
|
|
) -> Result<PuzzleClearSessionSnapshotResponse, SpacetimeClientError> {
|
|
self.call_after_connect("compile_puzzle_clear_draft", move |connection, sender| {
|
|
connection.procedures().compile_puzzle_clear_draft_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_agent_session_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn mark_puzzle_clear_generation_failed(
|
|
&self,
|
|
session_id: String,
|
|
owner_user_id: String,
|
|
author_display_name: String,
|
|
payload: PuzzleClearActionRequest,
|
|
) -> Result<PuzzleClearSessionSnapshotResponse, SpacetimeClientError> {
|
|
let current = self
|
|
.get_puzzle_clear_session(session_id, owner_user_id.clone())
|
|
.await?;
|
|
let procedure_input = build_failed_compile_input(
|
|
¤t,
|
|
&owner_user_id,
|
|
&author_display_name,
|
|
&payload,
|
|
current_unix_micros(),
|
|
)?;
|
|
self.compile_puzzle_clear_draft(procedure_input).await
|
|
}
|
|
|
|
pub async fn get_puzzle_clear_work_profile(
|
|
&self,
|
|
profile_id: String,
|
|
owner_user_id: String,
|
|
) -> Result<PuzzleClearWorkProfileResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearWorkGetInput {
|
|
profile_id,
|
|
owner_user_id,
|
|
};
|
|
|
|
self.call_after_connect(
|
|
"get_puzzle_clear_work_profile",
|
|
move |connection, sender| {
|
|
connection.procedures().get_puzzle_clear_work_profile_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_work_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn update_puzzle_clear_work(
|
|
&self,
|
|
procedure_input: PuzzleClearWorkUpdateInput,
|
|
) -> Result<PuzzleClearWorkProfileResponse, SpacetimeClientError> {
|
|
self.call_after_connect("update_puzzle_clear_work", move |connection, sender| {
|
|
connection.procedures().update_puzzle_clear_work_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_work_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn publish_puzzle_clear_work(
|
|
&self,
|
|
profile_id: String,
|
|
owner_user_id: String,
|
|
) -> Result<PuzzleClearWorkProfileResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearWorkPublishInput {
|
|
profile_id,
|
|
owner_user_id,
|
|
published_at_micros: current_unix_micros(),
|
|
};
|
|
|
|
self.call_after_connect("publish_puzzle_clear_work", move |connection, sender| {
|
|
connection.procedures().publish_puzzle_clear_work_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_work_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn list_puzzle_clear_works(
|
|
&self,
|
|
owner_user_id: String,
|
|
) -> Result<Vec<PuzzleClearWorkProfileResponse>, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearWorksListInput {
|
|
owner_user_id,
|
|
published_only: false,
|
|
};
|
|
|
|
self.call_after_connect("list_puzzle_clear_works", move |connection, sender| {
|
|
connection.procedures().list_puzzle_clear_works_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_works_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn get_puzzle_clear_runtime_work(
|
|
&self,
|
|
profile_id: String,
|
|
) -> Result<PuzzleClearWorkProfileResponse, SpacetimeClientError> {
|
|
let work = self
|
|
.get_puzzle_clear_work_profile(profile_id, String::new())
|
|
.await?;
|
|
validate_puzzle_clear_runtime_ready(&work)?;
|
|
Ok(work)
|
|
}
|
|
|
|
pub async fn start_puzzle_clear_run(
|
|
&self,
|
|
payload: PuzzleClearStartRunRequest,
|
|
owner_user_id: String,
|
|
) -> Result<PuzzleClearRuntimeSnapshotResponse, SpacetimeClientError> {
|
|
let profile_id = payload.profile_id;
|
|
let work = self
|
|
.get_puzzle_clear_work_profile(profile_id.clone(), String::new())
|
|
.await?;
|
|
validate_puzzle_clear_runtime_ready(&work)?;
|
|
let run_id = build_prefixed_uuid_id(PUZZLE_CLEAR_RUN_ID_PREFIX);
|
|
let procedure_input = PuzzleClearRunStartInput {
|
|
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_puzzle_clear_run_with_input(procedure_input)
|
|
.await
|
|
}
|
|
|
|
pub async fn start_puzzle_clear_run_with_input(
|
|
&self,
|
|
procedure_input: PuzzleClearRunStartInput,
|
|
) -> Result<PuzzleClearRuntimeSnapshotResponse, SpacetimeClientError> {
|
|
self.call_after_connect(
|
|
"start_puzzle_clear_runtime_run",
|
|
move |connection, sender| {
|
|
connection.procedures().start_puzzle_clear_runtime_run_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_run_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_puzzle_clear_run(
|
|
&self,
|
|
run_id: String,
|
|
owner_user_id: String,
|
|
) -> Result<PuzzleClearRuntimeSnapshotResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearRunGetInput {
|
|
run_id,
|
|
owner_user_id,
|
|
};
|
|
|
|
self.call_after_connect("get_puzzle_clear_runtime_run", move |connection, sender| {
|
|
connection.procedures().get_puzzle_clear_runtime_run_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_run_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn swap_puzzle_clear_cards(
|
|
&self,
|
|
run_id: String,
|
|
owner_user_id: String,
|
|
payload: PuzzleClearSwapRequest,
|
|
) -> Result<PuzzleClearRuntimeSnapshotResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearRunSwapInput {
|
|
run_id,
|
|
owner_user_id,
|
|
from_row: payload.from_row,
|
|
from_col: payload.from_col,
|
|
to_row: payload.to_row,
|
|
to_col: payload.to_col,
|
|
client_action_id: payload.client_action_id,
|
|
swapped_at_ms: current_unix_micros().div_euclid(1000),
|
|
};
|
|
|
|
self.call_after_connect("swap_puzzle_clear_cards", move |connection, sender| {
|
|
connection.procedures().swap_puzzle_clear_cards_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_run_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn retry_puzzle_clear_level(
|
|
&self,
|
|
run_id: String,
|
|
owner_user_id: String,
|
|
payload: PuzzleClearRetryLevelRequest,
|
|
) -> Result<PuzzleClearRuntimeSnapshotResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearRunRetryLevelInput {
|
|
run_id,
|
|
owner_user_id,
|
|
client_action_id: payload.client_action_id,
|
|
restarted_at_ms: current_unix_micros().div_euclid(1000),
|
|
};
|
|
|
|
self.call_after_connect("retry_puzzle_clear_level_run", move |connection, sender| {
|
|
connection.procedures().retry_puzzle_clear_level_run_then(
|
|
procedure_input,
|
|
move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_run_procedure_result);
|
|
send_once(&sender, mapped);
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn advance_puzzle_clear_next_level(
|
|
&self,
|
|
run_id: String,
|
|
owner_user_id: String,
|
|
payload: PuzzleClearNextLevelRequest,
|
|
) -> Result<PuzzleClearRuntimeSnapshotResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearRunNextLevelInput {
|
|
run_id,
|
|
owner_user_id,
|
|
client_action_id: payload.client_action_id,
|
|
started_at_ms: current_unix_micros().div_euclid(1000),
|
|
};
|
|
|
|
self.call_after_connect(
|
|
"advance_puzzle_clear_next_level",
|
|
move |connection, sender| {
|
|
connection
|
|
.procedures()
|
|
.advance_puzzle_clear_next_level_then(procedure_input, move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_run_procedure_result);
|
|
send_once(&sender, mapped);
|
|
});
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn mark_puzzle_clear_level_time_up(
|
|
&self,
|
|
run_id: String,
|
|
owner_user_id: String,
|
|
payload: PuzzleClearTimeUpRequest,
|
|
) -> Result<PuzzleClearRuntimeSnapshotResponse, SpacetimeClientError> {
|
|
let procedure_input = PuzzleClearRunTimeUpInput {
|
|
run_id,
|
|
owner_user_id,
|
|
client_action_id: payload.client_action_id,
|
|
occurred_at_ms: current_unix_micros().div_euclid(1000),
|
|
};
|
|
|
|
self.call_after_connect(
|
|
"mark_puzzle_clear_level_time_up",
|
|
move |connection, sender| {
|
|
connection
|
|
.procedures()
|
|
.mark_puzzle_clear_level_time_up_then(procedure_input, move |_, result| {
|
|
let mapped = result
|
|
.map_err(SpacetimeClientError::from_sdk_error)
|
|
.and_then(map_puzzle_clear_run_procedure_result);
|
|
send_once(&sender, mapped);
|
|
});
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_puzzle_clear_gallery(
|
|
&self,
|
|
) -> Result<Vec<PuzzleClearWorkSummaryResponse>, SpacetimeClientError> {
|
|
self.read_after_connect("list_puzzle_clear_gallery", move |connection| {
|
|
let mut items = connection
|
|
.db()
|
|
.puzzle_clear_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(items
|
|
.into_iter()
|
|
.map(map_puzzle_clear_gallery_card_view_row)
|
|
.collect())
|
|
})
|
|
.await
|
|
}
|
|
}
|
|
|
|
enum PuzzleClearActionProcedure {
|
|
Compile(PuzzleClearDraftCompileInput),
|
|
Update(PuzzleClearWorkUpdateInput),
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum PuzzleClearDraftMergeScope {
|
|
CompileDraft,
|
|
RegenerateAtlas,
|
|
UpdateWorkMeta,
|
|
UpdateBoardBackground,
|
|
}
|
|
|
|
fn build_puzzle_clear_action_plan(
|
|
current: &PuzzleClearSessionSnapshotResponse,
|
|
owner_user_id: &str,
|
|
author_display_name: &str,
|
|
payload: &PuzzleClearActionRequest,
|
|
now_micros: i64,
|
|
) -> Result<(PuzzleClearActionProcedure, PuzzleClearDraftResponse), SpacetimeClientError> {
|
|
let scope = match payload.action_type {
|
|
PuzzleClearActionType::CompileDraft => PuzzleClearDraftMergeScope::CompileDraft,
|
|
PuzzleClearActionType::RegenerateAtlas => PuzzleClearDraftMergeScope::RegenerateAtlas,
|
|
PuzzleClearActionType::UpdateWorkMeta => PuzzleClearDraftMergeScope::UpdateWorkMeta,
|
|
PuzzleClearActionType::UpdateBoardBackground => {
|
|
PuzzleClearDraftMergeScope::UpdateBoardBackground
|
|
}
|
|
};
|
|
let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?;
|
|
let profile_id = resolve_puzzle_clear_profile_id(
|
|
&draft,
|
|
&payload.action_type,
|
|
payload.profile_id.as_deref(),
|
|
)?;
|
|
draft.profile_id = Some(profile_id.clone());
|
|
let procedure =
|
|
match payload.action_type {
|
|
PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas => {
|
|
PuzzleClearActionProcedure::Compile(build_compile_input(
|
|
current,
|
|
owner_user_id,
|
|
author_display_name,
|
|
&profile_id,
|
|
&mut draft,
|
|
now_micros,
|
|
)?)
|
|
}
|
|
PuzzleClearActionType::UpdateWorkMeta
|
|
| PuzzleClearActionType::UpdateBoardBackground => PuzzleClearActionProcedure::Update(
|
|
build_update_input(owner_user_id, &profile_id, &draft, now_micros)?,
|
|
),
|
|
};
|
|
|
|
Ok((procedure, draft))
|
|
}
|
|
|
|
fn merge_action_into_draft(
|
|
draft: Option<PuzzleClearDraftResponse>,
|
|
payload: &PuzzleClearActionRequest,
|
|
scope: PuzzleClearDraftMergeScope,
|
|
) -> Result<PuzzleClearDraftResponse, SpacetimeClientError> {
|
|
let mut draft = draft.unwrap_or_else(default_draft);
|
|
if matches!(
|
|
scope,
|
|
PuzzleClearDraftMergeScope::CompileDraft
|
|
| PuzzleClearDraftMergeScope::UpdateWorkMeta
|
|
| PuzzleClearDraftMergeScope::RegenerateAtlas
|
|
) {
|
|
if let Some(value) = payload
|
|
.work_title
|
|
.as_ref()
|
|
.and_then(|value| non_empty_str(value))
|
|
{
|
|
draft.work_title = value;
|
|
}
|
|
if let Some(value) = payload.work_description.as_ref() {
|
|
draft.work_description = value.trim().to_string();
|
|
}
|
|
if let Some(value) = payload
|
|
.theme_prompt
|
|
.as_ref()
|
|
.and_then(|value| non_empty_str(value))
|
|
{
|
|
draft.theme_prompt = value;
|
|
}
|
|
if let Some(value) = payload
|
|
.board_background_prompt
|
|
.as_ref()
|
|
.and_then(|value| non_empty_str(value))
|
|
{
|
|
draft.board_background_prompt = value;
|
|
}
|
|
}
|
|
if matches!(
|
|
scope,
|
|
PuzzleClearDraftMergeScope::CompileDraft
|
|
| PuzzleClearDraftMergeScope::UpdateBoardBackground
|
|
| PuzzleClearDraftMergeScope::RegenerateAtlas
|
|
) {
|
|
if let Some(value) = payload.generate_board_background {
|
|
draft.generate_board_background = value;
|
|
}
|
|
if payload.board_background_asset.is_some() {
|
|
draft.board_background_asset = payload.board_background_asset.clone();
|
|
}
|
|
}
|
|
if matches!(
|
|
scope,
|
|
PuzzleClearDraftMergeScope::CompileDraft | PuzzleClearDraftMergeScope::RegenerateAtlas
|
|
) {
|
|
if let Some(asset) = payload.atlas_asset.clone() {
|
|
draft.atlas_asset = Some(asset);
|
|
}
|
|
if let Some(groups) = payload.pattern_groups.clone() {
|
|
draft.pattern_groups = groups;
|
|
}
|
|
if let Some(cards) = payload.card_assets.clone() {
|
|
draft.card_assets = cards;
|
|
}
|
|
if draft.pattern_groups.is_empty() {
|
|
draft.pattern_groups = default_pattern_groups();
|
|
}
|
|
draft.generation_status = PuzzleClearGenerationStatus::Ready;
|
|
}
|
|
|
|
if draft.work_title.trim().is_empty() || draft.theme_prompt.trim().is_empty() {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear 草稿需要标题和主题词",
|
|
));
|
|
}
|
|
Ok(draft)
|
|
}
|
|
|
|
fn build_compile_input(
|
|
current: &PuzzleClearSessionSnapshotResponse,
|
|
owner_user_id: &str,
|
|
author_display_name: &str,
|
|
profile_id: &str,
|
|
draft: &mut PuzzleClearDraftResponse,
|
|
now_micros: i64,
|
|
) -> Result<PuzzleClearDraftCompileInput, SpacetimeClientError> {
|
|
if draft.pattern_groups.is_empty() {
|
|
draft.pattern_groups = default_pattern_groups();
|
|
}
|
|
let atlas_asset = ensure_real_puzzle_clear_atlas_asset(draft.atlas_asset.as_ref())?;
|
|
ensure_real_puzzle_clear_card_assets(&draft.card_assets)?;
|
|
|
|
Ok(PuzzleClearDraftCompileInput {
|
|
session_id: current.session_id.clone(),
|
|
owner_user_id: owner_user_id.to_string(),
|
|
profile_id: profile_id.to_string(),
|
|
author_display_name: non_empty_str(author_display_name)
|
|
.unwrap_or_else(|| "拼消消玩家".to_string()),
|
|
work_title: draft.work_title.clone(),
|
|
work_description: draft.work_description.clone(),
|
|
theme_prompt: draft.theme_prompt.clone(),
|
|
board_background_prompt: draft.board_background_prompt.clone(),
|
|
generate_board_background: draft.generate_board_background,
|
|
board_background_asset_json: draft
|
|
.board_background_asset
|
|
.as_ref()
|
|
.map(json_string)
|
|
.transpose()?,
|
|
atlas_asset_json: Some(json_string(atlas_asset)?),
|
|
pattern_groups_json: Some(json_string(&draft.pattern_groups)?),
|
|
card_assets_json: Some(json_string(&draft.card_assets)?),
|
|
generation_status: Some("ready".to_string()),
|
|
compiled_at_micros: now_micros,
|
|
})
|
|
}
|
|
|
|
fn build_failed_compile_input(
|
|
current: &PuzzleClearSessionSnapshotResponse,
|
|
owner_user_id: &str,
|
|
author_display_name: &str,
|
|
payload: &PuzzleClearActionRequest,
|
|
now_micros: i64,
|
|
) -> Result<PuzzleClearDraftCompileInput, SpacetimeClientError> {
|
|
let mut draft = current.draft.clone().unwrap_or_else(default_draft);
|
|
if let Some(value) = payload
|
|
.work_title
|
|
.as_ref()
|
|
.and_then(|value| non_empty_str(value))
|
|
{
|
|
draft.work_title = value;
|
|
}
|
|
if let Some(value) = payload.work_description.as_ref() {
|
|
draft.work_description = value.trim().to_string();
|
|
}
|
|
if let Some(value) = payload
|
|
.theme_prompt
|
|
.as_ref()
|
|
.and_then(|value| non_empty_str(value))
|
|
{
|
|
draft.theme_prompt = value;
|
|
}
|
|
if let Some(value) = payload
|
|
.board_background_prompt
|
|
.as_ref()
|
|
.and_then(|value| non_empty_str(value))
|
|
{
|
|
draft.board_background_prompt = value;
|
|
}
|
|
if let Some(value) = payload.generate_board_background {
|
|
draft.generate_board_background = value;
|
|
}
|
|
if let Some(asset) = payload.board_background_asset.clone() {
|
|
draft.board_background_asset = Some(asset);
|
|
}
|
|
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());
|
|
}
|
|
draft.generation_status = PuzzleClearGenerationStatus::Failed;
|
|
let profile_id = resolve_puzzle_clear_profile_id(
|
|
&draft,
|
|
&PuzzleClearActionType::CompileDraft,
|
|
draft.profile_id.as_deref(),
|
|
)?;
|
|
draft.profile_id = Some(profile_id.clone());
|
|
|
|
Ok(PuzzleClearDraftCompileInput {
|
|
session_id: current.session_id.clone(),
|
|
owner_user_id: owner_user_id.to_string(),
|
|
profile_id,
|
|
author_display_name: non_empty_str(author_display_name)
|
|
.unwrap_or_else(|| "拼消消玩家".to_string()),
|
|
work_title: non_empty_str(draft.work_title.as_str())
|
|
.unwrap_or_else(|| PUZZLE_CLEAR_TEMPLATE_NAME.to_string()),
|
|
work_description: draft.work_description.trim().to_string(),
|
|
theme_prompt: non_empty_str(draft.theme_prompt.as_str())
|
|
.unwrap_or_else(|| PUZZLE_CLEAR_TEMPLATE_NAME.to_string()),
|
|
board_background_prompt: draft.board_background_prompt.clone(),
|
|
generate_board_background: draft.generate_board_background,
|
|
board_background_asset_json: draft
|
|
.board_background_asset
|
|
.as_ref()
|
|
.map(json_string)
|
|
.transpose()?,
|
|
atlas_asset_json: None,
|
|
pattern_groups_json: None,
|
|
card_assets_json: None,
|
|
generation_status: Some("failed".to_string()),
|
|
compiled_at_micros: now_micros,
|
|
})
|
|
}
|
|
|
|
fn build_update_input(
|
|
owner_user_id: &str,
|
|
profile_id: &str,
|
|
draft: &PuzzleClearDraftResponse,
|
|
now_micros: i64,
|
|
) -> Result<PuzzleClearWorkUpdateInput, SpacetimeClientError> {
|
|
Ok(PuzzleClearWorkUpdateInput {
|
|
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_prompt: draft.theme_prompt.clone(),
|
|
board_background_prompt: draft.board_background_prompt.clone(),
|
|
generate_board_background: draft.generate_board_background,
|
|
board_background_asset_json: draft
|
|
.board_background_asset
|
|
.as_ref()
|
|
.map(json_string)
|
|
.transpose()?,
|
|
updated_at_micros: now_micros,
|
|
})
|
|
}
|
|
|
|
fn resolve_puzzle_clear_profile_id(
|
|
draft: &PuzzleClearDraftResponse,
|
|
action_type: &PuzzleClearActionType,
|
|
payload_profile_id: Option<&str>,
|
|
) -> Result<String, SpacetimeClientError> {
|
|
if let Some(profile_id) = payload_profile_id
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
return Ok(profile_id.to_string());
|
|
}
|
|
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, PuzzleClearActionType::CompileDraft) {
|
|
return Ok(build_prefixed_uuid_id(PUZZLE_CLEAR_PROFILE_ID_PREFIX));
|
|
}
|
|
Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear action 需要先完成 compile-draft",
|
|
))
|
|
}
|
|
|
|
fn apply_puzzle_clear_work_to_session(
|
|
mut session: PuzzleClearSessionSnapshotResponse,
|
|
work: &PuzzleClearWorkProfileResponse,
|
|
) -> PuzzleClearSessionSnapshotResponse {
|
|
session.status = work.draft.generation_status.clone();
|
|
session.draft = Some(work.draft.clone());
|
|
session.updated_at = work.summary.updated_at.clone();
|
|
session
|
|
}
|
|
|
|
fn validate_puzzle_clear_runtime_ready(
|
|
work: &PuzzleClearWorkProfileResponse,
|
|
) -> Result<(), SpacetimeClientError> {
|
|
if work.summary.publication_status != "published" {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear runtime 只能启动已发布作品",
|
|
));
|
|
}
|
|
if work.summary.generation_status != PuzzleClearGenerationStatus::Ready {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear runtime 需要 ready 状态作品",
|
|
));
|
|
}
|
|
if work.card_assets.is_empty() || work.pattern_groups.is_empty() {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear runtime 缺少切片卡牌资产",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_real_puzzle_clear_atlas_asset(
|
|
asset: Option<&PuzzleClearImageAsset>,
|
|
) -> Result<&PuzzleClearImageAsset, SpacetimeClientError> {
|
|
let Some(asset) = asset else {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear atlas 缺少真实生成资产",
|
|
));
|
|
};
|
|
if !is_real_puzzle_clear_asset(
|
|
asset.asset_object_id.as_str(),
|
|
asset.image_object_key.as_str(),
|
|
asset.image_src.as_str(),
|
|
) {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear atlas 缺少真实生成资产",
|
|
));
|
|
}
|
|
Ok(asset)
|
|
}
|
|
|
|
fn ensure_real_puzzle_clear_card_assets(
|
|
assets: &[PuzzleClearCardAsset],
|
|
) -> Result<(), SpacetimeClientError> {
|
|
if assets.is_empty() {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear card assets 缺少真实生成资产",
|
|
));
|
|
}
|
|
if assets.iter().any(|asset| {
|
|
!is_real_puzzle_clear_asset(
|
|
asset.asset_object_id.as_str(),
|
|
asset.image_object_key.as_str(),
|
|
asset.image_src.as_str(),
|
|
)
|
|
}) {
|
|
return Err(SpacetimeClientError::validation_failed(
|
|
"puzzle-clear card assets 缺少真实生成资产",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_real_puzzle_clear_asset(
|
|
asset_object_id: &str,
|
|
image_object_key: &str,
|
|
image_src: &str,
|
|
) -> bool {
|
|
asset_object_id.starts_with(PUZZLE_CLEAR_ASSET_OBJECT_ID_PREFIX)
|
|
&& image_object_key.starts_with("generated-puzzle-clear-assets/")
|
|
&& image_src.starts_with("/generated-puzzle-clear-assets/")
|
|
}
|
|
|
|
fn default_draft() -> PuzzleClearDraftResponse {
|
|
PuzzleClearDraftResponse {
|
|
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
|
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
profile_id: None,
|
|
work_title: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
work_description: String::new(),
|
|
theme_prompt: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
board_background_prompt: String::new(),
|
|
generate_board_background: true,
|
|
board_background_asset: None,
|
|
card_back_image_src: Some("/creation-type-references/puzzle.webp".to_string()),
|
|
atlas_asset: None,
|
|
pattern_groups: Vec::new(),
|
|
card_assets: Vec::new(),
|
|
generation_status: PuzzleClearGenerationStatus::Draft,
|
|
}
|
|
}
|
|
|
|
fn default_pattern_groups() -> Vec<PuzzleClearPatternGroup> {
|
|
module_puzzle_clear::plan_puzzle_clear_pattern_groups(PUZZLE_CLEAR_ATLAS_CELL_SIZE)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|group| PuzzleClearPatternGroup {
|
|
group_id: group.group_id,
|
|
shape: group.shape.as_str().to_string(),
|
|
width: group.width,
|
|
height: group.height,
|
|
atlas_x: group.atlas_x,
|
|
atlas_y: group.atlas_y,
|
|
atlas_width: group.atlas_width,
|
|
atlas_height: group.atlas_height,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn non_empty_str(value: &str) -> Option<String> {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
None
|
|
} else {
|
|
Some(value.to_string())
|
|
}
|
|
}
|
|
|
|
fn json_string<T: serde::Serialize>(value: &T) -> Result<String, SpacetimeClientError> {
|
|
serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SESSION_ID: &str = "puzzle-clear-session-test";
|
|
const OWNER_USER_ID: &str = "user-test";
|
|
const NOW_MICROS: i64 = 1_780_000_000_000_000;
|
|
|
|
#[test]
|
|
fn puzzle_clear_compile_requires_real_atlas_assets_from_api_server() {
|
|
let session = session_with_draft(draft_without_assets());
|
|
let payload = action(PuzzleClearActionType::CompileDraft);
|
|
|
|
let error = match build_puzzle_clear_action_plan(
|
|
&session,
|
|
OWNER_USER_ID,
|
|
"拼消消玩家",
|
|
&payload,
|
|
NOW_MICROS,
|
|
) {
|
|
Ok(_) => panic!("compile-draft should not synthesize placeholder atlas assets"),
|
|
Err(error) => error,
|
|
};
|
|
|
|
assert!(error.to_string().contains("atlas"));
|
|
assert!(error.to_string().contains("真实生成资产"));
|
|
}
|
|
|
|
#[test]
|
|
fn puzzle_clear_failure_writeback_does_not_require_generated_assets() {
|
|
let session = session_with_draft(draft_without_assets());
|
|
|
|
let input = build_failed_compile_input(
|
|
&session,
|
|
OWNER_USER_ID,
|
|
"拼消消玩家",
|
|
&PuzzleClearActionRequest {
|
|
action_type: PuzzleClearActionType::CompileDraft,
|
|
profile_id: None,
|
|
work_title: None,
|
|
work_description: Some("VectorEngine 素材 atlas 生成失败".to_string()),
|
|
theme_prompt: None,
|
|
board_background_prompt: None,
|
|
generate_board_background: None,
|
|
board_background_asset: None,
|
|
atlas_asset: None,
|
|
pattern_groups: None,
|
|
card_assets: None,
|
|
},
|
|
NOW_MICROS,
|
|
)
|
|
.expect("failed writeback input should be buildable without assets");
|
|
|
|
assert_eq!(input.session_id, SESSION_ID);
|
|
assert_eq!(input.owner_user_id, OWNER_USER_ID);
|
|
assert_eq!(input.generation_status.as_deref(), Some("failed"));
|
|
assert!(input.atlas_asset_json.is_none());
|
|
assert!(input.pattern_groups_json.is_none());
|
|
assert!(input.card_assets_json.is_none());
|
|
assert_eq!(input.work_title, "水果拼消消");
|
|
assert_eq!(input.theme_prompt, "水果");
|
|
assert_eq!(input.work_description, "VectorEngine 素材 atlas 生成失败");
|
|
}
|
|
|
|
fn session_with_draft(draft: PuzzleClearDraftResponse) -> PuzzleClearSessionSnapshotResponse {
|
|
PuzzleClearSessionSnapshotResponse {
|
|
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-30T00:00:00Z".to_string(),
|
|
updated_at: "2026-05-30T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
fn draft_without_assets() -> PuzzleClearDraftResponse {
|
|
PuzzleClearDraftResponse {
|
|
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
|
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
|
profile_id: None,
|
|
work_title: "水果拼消消".to_string(),
|
|
work_description: String::new(),
|
|
theme_prompt: "水果".to_string(),
|
|
board_background_prompt: String::new(),
|
|
generate_board_background: false,
|
|
board_background_asset: None,
|
|
card_back_image_src: Some("/creation-type-references/puzzle.webp".to_string()),
|
|
atlas_asset: None,
|
|
pattern_groups: Vec::new(),
|
|
card_assets: Vec::new(),
|
|
generation_status: PuzzleClearGenerationStatus::Draft,
|
|
}
|
|
}
|
|
|
|
fn action(action_type: PuzzleClearActionType) -> PuzzleClearActionRequest {
|
|
PuzzleClearActionRequest {
|
|
action_type,
|
|
profile_id: None,
|
|
work_title: None,
|
|
work_description: None,
|
|
theme_prompt: None,
|
|
board_background_prompt: None,
|
|
generate_board_background: None,
|
|
board_background_asset: None,
|
|
atlas_asset: None,
|
|
pattern_groups: None,
|
|
card_assets: None,
|
|
}
|
|
}
|
|
}
|