use crate::runtime::{ ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays, grant_profile_wallet_points, record_public_work_like, record_public_work_play, upsert_profile_played_work, upsert_profile_save_archive, }; use module_puzzle::{ PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK, PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed, build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score, }; use module_runtime::RuntimeProfileWalletLedgerSourceType; use serde_json::from_str as json_from_str; use serde_json::json; use serde_json::to_string as json_to_string; use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext}; const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; /// 拼图 Agent session 真相表。 /// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。 #[spacetimedb::table( accessor = puzzle_agent_session, index(accessor = by_puzzle_agent_session_owner_user_id, btree(columns = [owner_user_id])) )] pub struct PuzzleAgentSessionRow { #[primary_key] session_id: String, owner_user_id: String, seed_text: String, current_turn: u32, progress_percent: u32, stage: PuzzleAgentStage, anchor_pack_json: String, draft_json: Option, last_assistant_reply: Option, published_profile_id: Option, created_at: Timestamp, updated_at: Timestamp, } /// 拼图 Agent 消息真相表。 #[spacetimedb::table( accessor = puzzle_agent_message, index(accessor = by_puzzle_agent_message_session_id, btree(columns = [session_id])) )] pub struct PuzzleAgentMessageRow { #[primary_key] message_id: String, session_id: String, role: PuzzleAgentMessageRole, kind: PuzzleAgentMessageKind, text: String, created_at: Timestamp, } /// 已发布与草稿作品统一作品表。 #[spacetimedb::table( accessor = puzzle_work_profile, index(accessor = by_puzzle_work_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_puzzle_work_publication_status, btree(columns = [publication_status])) )] pub struct PuzzleWorkProfileRow { #[primary_key] profile_id: String, work_id: String, owner_user_id: String, source_session_id: Option, author_display_name: String, work_title: String, work_description: String, level_name: String, summary: String, theme_tags_json: String, cover_image_src: Option, cover_asset_id: Option, levels_json: String, publication_status: PuzzlePublicationStatus, play_count: u32, anchor_pack_json: String, publish_ready: bool, created_at: Timestamp, updated_at: Timestamp, published_at: Option, #[default(0)] remix_count: u32, #[default(0)] like_count: u32, #[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)] point_incentive_total_half_points: u64, #[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)] point_incentive_claimed_points: u64, } /// 拼图创作事件类型。 /// /// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以 /// `puzzle_work_profile` 和 `puzzle_agent_session` 为准。 #[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] pub enum PuzzleEventKind { WorkPublished, } #[spacetimedb::table( accessor = puzzle_event, public, event, index(accessor = by_puzzle_event_profile_id, btree(columns = [profile_id])), index(accessor = by_puzzle_event_owner_user_id, btree(columns = [owner_user_id])) )] pub struct PuzzleEvent { #[primary_key] event_id: String, profile_id: String, work_id: String, session_id: Option, owner_user_id: String, event_kind: PuzzleEventKind, occurred_at: Timestamp, } /// 运行态 run 快照表。 #[spacetimedb::table( accessor = puzzle_runtime_run, index(accessor = by_puzzle_runtime_run_owner_user_id, btree(columns = [owner_user_id])) )] pub struct PuzzleRuntimeRunRow { #[primary_key] run_id: String, owner_user_id: String, entry_profile_id: String, current_profile_id: String, cleared_level_count: u32, current_level_index: u32, current_grid_size: u32, played_profile_ids_json: String, previous_level_tags_json: String, snapshot_json: String, created_at: Timestamp, updated_at: Timestamp, } /// 拼图关卡真实成绩表。 /// 每个用户在同一作品同一网格规格下只保留一条最佳成绩,用于结算弹窗排行榜。 #[spacetimedb::table( accessor = puzzle_leaderboard_entry, index(accessor = by_puzzle_leaderboard_profile_grid, btree(columns = [profile_id, grid_size])), index(accessor = by_puzzle_leaderboard_user_profile_grid, btree(columns = [user_id, profile_id, grid_size])) )] pub struct PuzzleLeaderboardEntryRow { #[primary_key] entry_id: String, profile_id: String, grid_size: u32, user_id: String, nickname: String, best_elapsed_ms: u64, last_run_id: String, updated_at: Timestamp, } #[spacetimedb::procedure] pub fn create_puzzle_agent_session( ctx: &mut ProcedureContext, input: PuzzleAgentSessionCreateInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_puzzle_agent_session_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_puzzle_agent_session( ctx: &mut ProcedureContext, input: PuzzleAgentSessionGetInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_puzzle_agent_session_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_puzzle_agent_message( ctx: &mut ProcedureContext, input: module_puzzle::PuzzleAgentMessageSubmitInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| submit_puzzle_agent_message_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn finalize_puzzle_agent_message_turn( ctx: &mut ProcedureContext, input: PuzzleAgentMessageFinalizeInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| finalize_puzzle_agent_message_turn_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn compile_puzzle_agent_draft( ctx: &mut ProcedureContext, input: PuzzleDraftCompileInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| compile_puzzle_agent_draft_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } /// 保存拼图入口表单草稿。 /// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 #[spacetimedb::procedure] pub fn save_puzzle_form_draft( ctx: &mut ProcedureContext, input: PuzzleFormDraftSaveInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| save_puzzle_form_draft_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn save_puzzle_generated_images( ctx: &mut ProcedureContext, input: PuzzleGeneratedImagesSaveInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| save_puzzle_generated_images_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn select_puzzle_cover_image( ctx: &mut ProcedureContext, input: PuzzleSelectCoverImageInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| select_puzzle_cover_image_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn publish_puzzle_work( ctx: &mut ProcedureContext, input: PuzzlePublishInput, ) -> PuzzleWorkProcedureResult { match ctx.try_with_tx(|tx| publish_puzzle_work_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, item_json: Some(serialize_json(&item)), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, item_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_puzzle_works( ctx: &mut ProcedureContext, input: PuzzleWorksListInput, ) -> PuzzleWorksProcedureResult { match ctx.try_with_tx(|tx| list_puzzle_works_tx(tx, input.clone())) { Ok(items) => PuzzleWorksProcedureResult { ok: true, items_json: Some(serialize_json(&items)), error_message: None, }, Err(message) => PuzzleWorksProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_puzzle_work_detail( ctx: &mut ProcedureContext, input: PuzzleWorkGetInput, ) -> PuzzleWorkProcedureResult { match ctx.try_with_tx(|tx| get_puzzle_work_detail_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, item_json: Some(serialize_json(&item)), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, item_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn update_puzzle_work( ctx: &mut ProcedureContext, input: PuzzleWorkUpsertInput, ) -> PuzzleWorkProcedureResult { match ctx.try_with_tx(|tx| update_puzzle_work_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, item_json: Some(serialize_json(&item)), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, item_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn delete_puzzle_work( ctx: &mut ProcedureContext, input: PuzzleWorkDeleteInput, ) -> PuzzleWorksProcedureResult { match ctx.try_with_tx(|tx| delete_puzzle_work_tx(tx, input.clone())) { Ok(items) => PuzzleWorksProcedureResult { ok: true, items_json: Some(serialize_json(&items)), error_message: None, }, Err(message) => PuzzleWorksProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_puzzle_gallery(ctx: &mut ProcedureContext) -> PuzzleWorksProcedureResult { match ctx.try_with_tx(|tx| list_puzzle_gallery_tx(tx)) { Ok(items) => PuzzleWorksProcedureResult { ok: true, items_json: Some(serialize_json(&items)), error_message: None, }, Err(message) => PuzzleWorksProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_puzzle_gallery_detail( ctx: &mut ProcedureContext, input: PuzzleWorkGetInput, ) -> PuzzleWorkProcedureResult { match ctx.try_with_tx(|tx| get_puzzle_gallery_detail_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, item_json: Some(serialize_json(&item)), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, item_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn record_puzzle_work_like( ctx: &mut ProcedureContext, input: PuzzleWorkLikeInput, ) -> PuzzleWorkProcedureResult { match ctx.try_with_tx(|tx| record_puzzle_work_like_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, item_json: Some(serialize_json(&item)), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, item_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn remix_puzzle_work( ctx: &mut ProcedureContext, input: PuzzleWorkRemixInput, ) -> PuzzleAgentSessionProcedureResult { match ctx.try_with_tx(|tx| remix_puzzle_work_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, session_json: Some(serialize_json(&session)), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn start_puzzle_run( ctx: &mut ProcedureContext, input: PuzzleRunStartInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| start_puzzle_run_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_puzzle_run( ctx: &mut ProcedureContext, input: PuzzleRunGetInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| get_puzzle_run_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn swap_puzzle_pieces( ctx: &mut ProcedureContext, input: PuzzleRunSwapInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| swap_puzzle_pieces_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn drag_puzzle_piece_or_group( ctx: &mut ProcedureContext, input: PuzzleRunDragInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| drag_puzzle_piece_or_group_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn advance_puzzle_next_level( ctx: &mut ProcedureContext, input: PuzzleRunNextLevelInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| advance_puzzle_next_level_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn update_puzzle_run_pause( ctx: &mut ProcedureContext, input: PuzzleRunPauseInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| update_puzzle_run_pause_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn use_puzzle_runtime_prop( ctx: &mut ProcedureContext, input: PuzzleRunPropInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| use_puzzle_runtime_prop_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn claim_puzzle_work_point_incentive( ctx: &mut ProcedureContext, input: PuzzleWorkPointIncentiveClaimInput, ) -> PuzzleWorkProcedureResult { match ctx.try_with_tx(|tx| claim_puzzle_work_point_incentive_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, item_json: Some(serialize_json(&item)), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, item_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_puzzle_leaderboard_entry( ctx: &mut ProcedureContext, input: PuzzleLeaderboardSubmitInput, ) -> PuzzleRunProcedureResult { match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, run_json: Some(serialize_json(&run)), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, run_json: None, error_message: Some(message), }, } } fn create_puzzle_agent_session_tx( ctx: &TxContext, input: PuzzleAgentSessionCreateInput, ) -> Result { ensure_session_missing(ctx, &input.session_id)?; ensure_message_missing(ctx, &input.welcome_message_id)?; let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text)); let initial_form_draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text)); ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.clone(), current_turn: 1, // 中文注释:欢迎语和初始锚点推断不计入创作进度,新会话必须从 0% 开始。 progress_percent: 0, stage: PuzzleAgentStage::CollectingAnchors, anchor_pack_json: serialize_json(&anchor_pack), draft_json: Some(serialize_json(&initial_form_draft)), last_assistant_reply: Some(input.welcome_message_text.clone()), published_profile_id: None, created_at, updated_at: created_at, }); ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: PuzzleAgentMessageRole::Assistant, kind: PuzzleAgentMessageKind::Chat, text: input.welcome_message_text, created_at, }); upsert_puzzle_draft_work_profile( ctx, &input.session_id, &input.owner_user_id, &initial_form_draft, input.created_at_micros, )?; get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_puzzle_agent_session_tx( ctx: &TxContext, input: PuzzleAgentSessionGetInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; build_puzzle_agent_session_snapshot(ctx, &row) } fn submit_puzzle_agent_message_tx( ctx: &TxContext, input: module_puzzle::PuzzleAgentMessageSubmitInput, ) -> Result { get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; ensure_message_missing(ctx, &input.user_message_id)?; let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { message_id: input.user_message_id.clone(), session_id: input.session_id.clone(), role: PuzzleAgentMessageRole::User, kind: PuzzleAgentMessageKind::Chat, text: input.user_message_text.clone(), created_at: submitted_at, }); get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn finalize_puzzle_agent_message_turn_tx( ctx: &TxContext, input: PuzzleAgentMessageFinalizeInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); if let Some(error_message) = input .error_message .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { replace_puzzle_agent_session( ctx, &row, PuzzleAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage, anchor_pack_json: row.anchor_pack_json.clone(), draft_json: row.draft_json.clone(), last_assistant_reply: row.last_assistant_reply.clone(), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at, }, ); return Err(error_message.to_string()); } let assistant_message_id = input .assistant_message_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "拼图 assistant_message_id 不能为空".to_string())? .to_string(); let assistant_reply_text = input .assistant_reply_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "拼图 assistant_reply_text 不能为空".to_string())? .to_string(); ensure_message_missing(ctx, &assistant_message_id)?; let next_anchor_pack = deserialize_anchor_pack(&input.anchor_pack_json)?; ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { message_id: assistant_message_id, session_id: input.session_id.clone(), role: PuzzleAgentMessageRole::Assistant, kind: PuzzleAgentMessageKind::Chat, text: assistant_reply_text.clone(), created_at: updated_at, }); replace_puzzle_agent_session( ctx, &row, PuzzleAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn.saturating_add(1), progress_percent: input.progress_percent.min(100), stage: input.stage, anchor_pack_json: serialize_json(&next_anchor_pack), draft_json: row.draft_json.clone(), last_assistant_reply: Some(assistant_reply_text), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at, }, ); get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn compile_puzzle_agent_draft_tx( ctx: &TxContext, input: PuzzleDraftCompileInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; if row.seed_text.trim().is_empty() { return Err("请先填写拼图作品信息".to_string()); } let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text)); let messages = list_session_messages(ctx, &row.session_id); let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)); // 创作中心的拼图草稿卡只是 Agent session 的列表投影, // 每次编译结果页时同步 upsert,保证后续能按 source_session_id 恢复聊天。 upsert_puzzle_draft_work_profile( ctx, &row.session_id, &row.owner_user_id, &draft, input.compiled_at_micros, )?; let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); replace_puzzle_agent_session( ctx, &row, PuzzleAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: 88, stage: PuzzleAgentStage::DraftReady, anchor_pack_json: serialize_json(&anchor_pack), draft_json: Some(serialize_json(&draft)), last_assistant_reply: Some( "拼图结果页草稿已经生成,可以开始出图并确认标签。".to_string(), ), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: compiled_at, }, ); get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn save_puzzle_form_draft_tx( ctx: &TxContext, input: PuzzleFormDraftSaveInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; if row.stage != PuzzleAgentStage::CollectingAnchors { return get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ); } let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text)); let draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text)); upsert_puzzle_draft_work_profile( ctx, &input.session_id, &input.owner_user_id, &draft, input.saved_at_micros, )?; replace_puzzle_agent_session( ctx, &row, PuzzleAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: input.seed_text, current_turn: row.current_turn, progress_percent: 0, stage: PuzzleAgentStage::CollectingAnchors, anchor_pack_json: serialize_json(&anchor_pack), draft_json: Some(serialize_json(&draft)), last_assistant_reply: row.last_assistant_reply.clone(), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: saved_at, }, ); get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn save_puzzle_generated_images_tx( ctx: &TxContext, input: PuzzleGeneratedImagesSaveInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; let previous_primary_level_name = draft.level_name.clone(); let previous_work_title = draft.work_title.clone(); if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { // 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。 draft.levels = levels; module_puzzle::sync_primary_level_fields(&mut draft); // 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。 sync_generated_primary_level_name_as_default_work_title( &mut draft, &previous_work_title, &previous_primary_level_name, ); } let candidates: Vec = json_from_str(&input.candidates_json) .map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?; if candidates.is_empty() { return Err("拼图候选图不能为空".to_string()); } let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) .ok_or_else(|| "拼图关卡不存在".to_string())?; let mut next_level = target_level; replace_generated_candidate(&mut next_level.candidates, candidates); next_level.generation_status = "ready".to_string(); if let Some(selected) = next_level .candidates .iter() .find(|entry| entry.selected) .cloned() { next_level.selected_candidate_id = Some(selected.candidate_id); next_level.cover_image_src = Some(selected.image_src); next_level.cover_asset_id = Some(selected.asset_id); } draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); let next_stage = if build_result_preview(&draft, Some("百梦主")).publish_ready { PuzzleAgentStage::ReadyToPublish } else { PuzzleAgentStage::ImageRefining }; // 结果页草稿封面和候选图发生变化后,草稿卡需要同步刷新。 upsert_puzzle_draft_work_profile( ctx, &row.session_id, &row.owner_user_id, &draft, input.saved_at_micros, )?; replace_puzzle_agent_session( ctx, &row, PuzzleAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: 94, stage: next_stage, anchor_pack_json: row.anchor_pack_json.clone(), draft_json: Some(serialize_json(&draft)), last_assistant_reply: Some("拼图图片已经生成,并已替换当前正式图。".to_string()), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: saved_at, }, ); get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn sync_generated_primary_level_name_as_default_work_title( draft: &mut PuzzleResultDraft, previous_work_title: &str, previous_primary_level_name: &str, ) { if previous_work_title.trim().is_empty() || previous_work_title.trim() == previous_primary_level_name.trim() { draft.work_title = draft.level_name.clone(); } } fn select_puzzle_cover_image_tx( ctx: &TxContext, input: PuzzleSelectCoverImageInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let draft = deserialize_draft_required(&row.draft_json)?; let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) .ok_or_else(|| "拼图关卡不存在".to_string())?; let level_draft = PuzzleResultDraft { work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), level_name: target_level.level_name.clone(), summary: draft.summary.clone(), theme_tags: draft.theme_tags.clone(), forbidden_directives: draft.forbidden_directives.clone(), creator_intent: draft.creator_intent.clone(), anchor_pack: draft.anchor_pack.clone(), candidates: target_level.candidates.clone(), selected_candidate_id: target_level.selected_candidate_id.clone(), cover_image_src: target_level.cover_image_src.clone(), cover_asset_id: target_level.cover_asset_id.clone(), generation_status: target_level.generation_status.clone(), levels: vec![target_level.clone()], form_draft: None, }; let selected_level_draft = apply_selected_candidate(level_draft, &input.candidate_id) .map_err(|error| error.to_string())?; let next_level = module_puzzle::PuzzleDraftLevel { level_id: target_level.level_id, level_name: target_level.level_name, picture_description: target_level.picture_description, candidates: selected_level_draft.candidates, selected_candidate_id: selected_level_draft.selected_candidate_id, cover_image_src: selected_level_draft.cover_image_src, cover_asset_id: selected_level_draft.cover_asset_id, generation_status: selected_level_draft.generation_status, }; let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros); let next_stage = if build_result_preview(&draft, Some("百梦主")).publish_ready { PuzzleAgentStage::ReadyToPublish } else { PuzzleAgentStage::ImageRefining }; // 选定正式封面后,创作中心草稿卡要立即反映最新正式图。 upsert_puzzle_draft_work_profile( ctx, &row.session_id, &row.owner_user_id, &draft, input.selected_at_micros, )?; replace_puzzle_agent_session( ctx, &row, PuzzleAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: 96, stage: next_stage, anchor_pack_json: row.anchor_pack_json.clone(), draft_json: Some(serialize_json(&draft)), last_assistant_reply: Some("正式拼图图片已确定,可以准备发布。".to_string()), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: selected_at, }, ); get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn publish_puzzle_work_tx( ctx: &TxContext, input: PuzzlePublishInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let draft = deserialize_draft_required(&row.draft_json)?; let draft = apply_publish_overrides_to_draft( &draft, input.work_title.clone(), input.work_description.clone(), input.level_name.clone(), input.summary.clone(), input.theme_tags.clone(), deserialize_optional_levels_input(input.levels_json.as_deref())?, ) .map_err(|error| error.to_string())?; let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(&input.session_id); let mut profile = create_work_profile( work_id, profile_id, input.owner_user_id.clone(), Some(input.session_id.clone()), input.author_display_name.clone(), &draft, input.published_at_micros, ) .map_err(|error| error.to_string())?; profile = publish_work_profile(profile, &draft, input.published_at_micros) .map_err(|error| error.to_string())?; upsert_puzzle_work_profile(ctx, profile.clone())?; replace_puzzle_agent_session( ctx, &row, PuzzleAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: 100, stage: PuzzleAgentStage::Published, anchor_pack_json: row.anchor_pack_json.clone(), draft_json: Some(serialize_json(&draft)), last_assistant_reply: Some("拼图作品已经发布到广场。".to_string()), published_profile_id: Some(profile.profile_id.clone()), created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.published_at_micros), }, ); emit_puzzle_work_published_event(ctx, &profile, input.published_at_micros); Ok(profile) } fn list_puzzle_works_tx( ctx: &TxContext, input: PuzzleWorksListInput, ) -> Result, String> { let mut items = ctx .db .puzzle_work_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id) .map(|row| build_puzzle_work_profile_from_row(&row)) .collect::, _>>()?; items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(items) } fn get_puzzle_work_detail_tx( ctx: &TxContext, input: PuzzleWorkGetInput, ) -> Result { let row = ctx .db .puzzle_work_profile() .profile_id() .find(&input.profile_id) .ok_or_else(|| "拼图作品不存在".to_string())?; build_puzzle_work_profile_from_row(&row) } fn update_puzzle_work_tx( ctx: &TxContext, input: PuzzleWorkUpsertInput, ) -> Result { let row = ctx .db .puzzle_work_profile() .profile_id() .find(&input.profile_id) .ok_or_else(|| "拼图作品不存在".to_string())?; if row.owner_user_id != input.owner_user_id { return Err("无权修改该拼图作品".to_string()); } let theme_tags = normalize_theme_tags(input.theme_tags); if theme_tags.len() > PUZZLE_MAX_TAG_COUNT { return Err("拼图标签数量不合法".to_string()); } let levels = deserialize_optional_levels_input(input.levels_json.as_deref())? .map(|levels| { normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string()) }) .transpose()? .unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default()); let preview_draft = PuzzleResultDraft { work_title: input.work_title.clone(), work_description: input.work_description.clone(), level_name: input.level_name.clone(), summary: input.summary.clone(), theme_tags: theme_tags.clone(), forbidden_directives: Vec::new(), creator_intent: None, anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, candidates: levels .first() .map(|level| level.candidates.clone()) .unwrap_or_default(), selected_candidate_id: levels .first() .and_then(|level| level.selected_candidate_id.clone()), cover_image_src: input.cover_image_src.clone(), cover_asset_id: input.cover_asset_id.clone(), generation_status: levels .first() .map(|level| level.generation_status.clone()) .unwrap_or_else(|| "idle".to_string()), levels: levels.clone(), form_draft: None, }; let next_row = PuzzleWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: input.work_title, work_description: input.work_description, level_name: input.level_name, summary: input.summary, theme_tags_json: serialize_json(&theme_tags), cover_image_src: input.cover_image_src, cover_asset_id: input.cover_asset_id, levels_json: serialize_json(&levels), publication_status: row.publication_status, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, point_incentive_total_half_points: row.point_incentive_total_half_points, point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: build_result_preview(&preview_draft, Some(&row.author_display_name)) .publish_ready, created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros), published_at: row.published_at, }; replace_puzzle_work_profile(ctx, &row, next_row); sync_puzzle_source_session_draft_from_work(ctx, &row, &preview_draft, input.updated_at_micros)?; get_puzzle_work_detail_tx( ctx, PuzzleWorkGetInput { profile_id: input.profile_id, }, ) } fn sync_puzzle_source_session_draft_from_work( ctx: &TxContext, work_row: &PuzzleWorkProfileRow, draft: &PuzzleResultDraft, updated_at_micros: i64, ) -> Result<(), String> { let Some(session_id) = work_row.source_session_id.as_ref() else { return Ok(()); }; let Some(session_row) = ctx.db.puzzle_agent_session().session_id().find(session_id) else { return Ok(()); }; if session_row.owner_user_id != work_row.owner_user_id { return Ok(()); } let normalized_draft = normalize_puzzle_draft(draft.clone()); let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); let next_stage = if session_row.stage == PuzzleAgentStage::Published { PuzzleAgentStage::Published } else if build_result_preview(&normalized_draft, Some(&work_row.author_display_name)) .publish_ready { PuzzleAgentStage::ReadyToPublish } else { PuzzleAgentStage::ImageRefining }; replace_puzzle_agent_session( ctx, &session_row, PuzzleAgentSessionRow { session_id: session_row.session_id.clone(), owner_user_id: session_row.owner_user_id.clone(), seed_text: session_row.seed_text.clone(), current_turn: session_row.current_turn, progress_percent: session_row.progress_percent.max(94), stage: next_stage, anchor_pack_json: session_row.anchor_pack_json.clone(), draft_json: Some(serialize_json(&normalized_draft)), last_assistant_reply: session_row.last_assistant_reply.clone(), published_profile_id: session_row.published_profile_id.clone(), created_at: session_row.created_at, updated_at, }, ); Ok(()) } fn delete_puzzle_work_tx( ctx: &TxContext, input: PuzzleWorkDeleteInput, ) -> Result, String> { let row = ctx .db .puzzle_work_profile() .profile_id() .find(&input.profile_id) .ok_or_else(|| "拼图作品不存在".to_string())?; if row.owner_user_id != input.owner_user_id { return Err("无权删除该拼图作品".to_string()); } // 删除作品时同步清理来源 Agent 会话和运行快照,保持创作页列表与运行态数据一致。 ctx.db .puzzle_work_profile() .profile_id() .delete(&row.profile_id); if let Some(session_id) = row.source_session_id.as_ref() { if let Some(session) = ctx.db.puzzle_agent_session().session_id().find(session_id) { ctx.db .puzzle_agent_session() .session_id() .delete(&session.session_id); } for message in ctx .db .puzzle_agent_message() .iter() .filter(|message| message.session_id == *session_id) .collect::>() { ctx.db .puzzle_agent_message() .message_id() .delete(&message.message_id); } } for run in ctx .db .puzzle_runtime_run() .iter() .filter(|run| { run.owner_user_id == input.owner_user_id && run.entry_profile_id == input.profile_id }) .collect::>() { ctx.db.puzzle_runtime_run().run_id().delete(&run.run_id); } list_puzzle_works_tx( ctx, PuzzleWorksListInput { owner_user_id: input.owner_user_id, }, ) } fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, String> { let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let mut items = ctx .db .puzzle_work_profile() .iter() .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) .map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros)) .collect::, _>>()?; items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(items) } fn get_puzzle_gallery_detail_tx( ctx: &TxContext, input: PuzzleWorkGetInput, ) -> Result { let row = ctx .db .puzzle_work_profile() .profile_id() .find(&input.profile_id) .ok_or_else(|| "拼图作品不存在".to_string())?; if row.publication_status != PuzzlePublicationStatus::Published { return Err("拼图作品尚未发布".to_string()); } build_puzzle_work_profile_from_row_with_recent_count( ctx, &row, ctx.timestamp.to_micros_since_unix_epoch(), ) } fn record_puzzle_work_like_tx( ctx: &TxContext, input: PuzzleWorkLikeInput, ) -> Result { let profile_id = input.profile_id.trim(); let user_id = input.user_id.trim(); if profile_id.is_empty() || user_id.is_empty() { return Err("拼图 like 参数不能为空".to_string()); } let row = ctx .db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) .ok_or_else(|| "拼图已发布作品不存在,无法点赞".to_string())?; let inserted_like = record_public_work_like( ctx, PublicWorkLikeRecordInput { source_type: "puzzle".to_string(), owner_user_id: row.owner_user_id.clone(), profile_id: row.profile_id.clone(), user_id: user_id.to_string(), liked_at_micros: input.liked_at_micros, }, )?; let current_row = if inserted_like { let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); let next_row = PuzzleWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags_json: row.theme_tags_json.clone(), cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), levels_json: row.levels_json.clone(), publication_status: row.publication_status, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count.saturating_add(1), point_incentive_total_half_points: row.point_incentive_total_half_points, point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, updated_at: liked_at, published_at: row.published_at, }; replace_puzzle_work_profile(ctx, &row, next_row); ctx.db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "拼图点赞更新失败".to_string())? } else { row }; build_puzzle_work_profile_from_row_with_recent_count( ctx, ¤t_row, ctx.timestamp.to_micros_since_unix_epoch(), ) } fn remix_puzzle_work_tx( ctx: &TxContext, input: PuzzleWorkRemixInput, ) -> Result { let source_profile_id = input.source_profile_id.trim(); let target_owner_user_id = input.target_owner_user_id.trim(); let target_session_id = input.target_session_id.trim(); let target_profile_id = input.target_profile_id.trim(); let target_work_id = input.target_work_id.trim(); if source_profile_id.is_empty() || target_owner_user_id.is_empty() || target_session_id.is_empty() || target_profile_id.is_empty() || target_work_id.is_empty() { return Err("拼图 remix 参数不能为空".to_string()); } if input.author_display_name.trim().is_empty() { return Err("拼图 remix 作者名不能为空".to_string()); } ensure_session_missing(ctx, target_session_id)?; ensure_message_missing(ctx, input.welcome_message_id.trim())?; if ctx .db .puzzle_work_profile() .profile_id() .find(&target_profile_id.to_string()) .is_some() { return Err("拼图 remix 目标作品已存在".to_string()); } let source = ctx .db .puzzle_work_profile() .profile_id() .find(&source_profile_id.to_string()) .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) .ok_or_else(|| "拼图已发布源作品不存在".to_string())?; let source_profile = build_puzzle_work_profile_from_row(&source)?; let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); replace_puzzle_work_profile( ctx, &source, PuzzleWorkProfileRow { profile_id: source.profile_id.clone(), work_id: source.work_id.clone(), owner_user_id: source.owner_user_id.clone(), source_session_id: source.source_session_id.clone(), author_display_name: source.author_display_name.clone(), work_title: source.work_title.clone(), work_description: source.work_description.clone(), level_name: source.level_name.clone(), summary: source.summary.clone(), theme_tags_json: source.theme_tags_json.clone(), cover_image_src: source.cover_image_src.clone(), cover_asset_id: source.cover_asset_id.clone(), levels_json: source.levels_json.clone(), publication_status: source.publication_status, play_count: source.play_count, remix_count: source.remix_count.saturating_add(1), like_count: source.like_count, point_incentive_total_half_points: source.point_incentive_total_half_points, point_incentive_claimed_points: source.point_incentive_claimed_points, anchor_pack_json: source.anchor_pack_json.clone(), publish_ready: source.publish_ready, created_at: source.created_at, updated_at: remixed_at, published_at: source.published_at, }, ); let draft = PuzzleResultDraft { work_title: source_profile.work_title.clone(), work_description: source_profile.work_description.clone(), level_name: source_profile.level_name.clone(), summary: source_profile.summary.clone(), theme_tags: source_profile.theme_tags.clone(), forbidden_directives: Vec::new(), creator_intent: None, anchor_pack: source_profile.anchor_pack.clone(), candidates: Vec::new(), selected_candidate_id: None, cover_image_src: source_profile.cover_image_src.clone(), cover_asset_id: source_profile.cover_asset_id.clone(), generation_status: "ready".to_string(), levels: source_profile.levels.clone(), form_draft: None, }; ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow { session_id: target_session_id.to_string(), owner_user_id: target_owner_user_id.to_string(), seed_text: source_profile.summary.clone(), current_turn: 1, progress_percent: 88, stage: PuzzleAgentStage::DraftReady, anchor_pack_json: serialize_json(&source_profile.anchor_pack), draft_json: Some(serialize_json(&draft)), last_assistant_reply: Some("已从公开作品 Remix 出新的拼图草稿。".to_string()), published_profile_id: None, created_at: remixed_at, updated_at: remixed_at, }); ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { message_id: input.welcome_message_id, session_id: target_session_id.to_string(), role: PuzzleAgentMessageRole::Assistant, kind: PuzzleAgentMessageKind::Summary, text: "已复制公开作品为你的草稿。".to_string(), created_at: remixed_at, }); ctx.db.puzzle_work_profile().insert(PuzzleWorkProfileRow { profile_id: target_profile_id.to_string(), work_id: target_work_id.to_string(), owner_user_id: target_owner_user_id.to_string(), source_session_id: Some(target_session_id.to_string()), author_display_name: input.author_display_name.trim().to_string(), work_title: source_profile.work_title, work_description: source_profile.work_description, level_name: source_profile.level_name, summary: source_profile.summary, theme_tags_json: serialize_json(&source_profile.theme_tags), cover_image_src: source_profile.cover_image_src, cover_asset_id: source_profile.cover_asset_id, levels_json: serialize_json(&source_profile.levels), publication_status: PuzzlePublicationStatus::Draft, play_count: 0, remix_count: 0, like_count: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, anchor_pack_json: serialize_json(&source_profile.anchor_pack), publish_ready: true, created_at: remixed_at, updated_at: remixed_at, published_at: None, }); get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { session_id: target_session_id.to_string(), owner_user_id: target_owner_user_id.to_string(), }, ) } fn start_puzzle_run_tx( ctx: &TxContext, input: PuzzleRunStartInput, ) -> Result { if ctx .db .puzzle_runtime_run() .run_id() .find(&input.run_id) .is_some() { return Err("拼图 run 已存在".to_string()); } let entry_profile_row = ctx .db .puzzle_work_profile() .profile_id() .find(&input.profile_id) .ok_or_else(|| "入口拼图作品不存在".to_string())?; // 结果页试玩允许作者启动自己的草稿 run;公开入口仍必须保持已发布状态。 let is_owner_draft_preview = entry_profile_row.publication_status == PuzzlePublicationStatus::Draft && entry_profile_row.owner_user_id == input.owner_user_id; if entry_profile_row.publication_status != PuzzlePublicationStatus::Published && !is_owner_draft_preview { return Err("入口拼图作品未发布".to_string()); } let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; if entry_profile.cover_image_src.is_none() { return Err("入口拼图作品缺少正式图片".to_string()); } if entry_profile.theme_tags.is_empty() { return Err("入口拼图作品缺少标签".to_string()); } let mut cleared_level_count = 0; if let Some(level) = selected_profile_level(&entry_profile, input.level_id.as_deref())? { cleared_level_count = module_puzzle::resolve_restart_cleared_level_count(&entry_profile, &level.level_id); entry_profile = profile_for_single_level(&entry_profile, &level); } let started_at_ms = micros_to_millis(input.started_at_micros); let mut run = module_puzzle::start_run_at( input.run_id.clone(), &entry_profile, cleared_level_count, started_at_ms, ) .map_err(|error| error.to_string())?; let current_grid_size = run.current_grid_size; let current_profile_id = entry_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( ctx, &mut run, &input.owner_user_id, current_profile_id.as_str(), current_grid_size, ); refresh_next_level_handoff(ctx, &mut run)?; if entry_profile_row.publication_status == PuzzlePublicationStatus::Published { record_public_work_play( ctx, PublicWorkPlayRecordInput { source_type: "puzzle".to_string(), owner_user_id: entry_profile_row.owner_user_id.clone(), profile_id: entry_profile_row.profile_id.clone(), played_at_micros: input.started_at_micros, }, )?; increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros); upsert_puzzle_profile_played_work( ctx, &input.owner_user_id, &entry_profile_row, input.started_at_micros, )?; } insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?; Ok(run) } fn get_puzzle_run_tx( ctx: &TxContext, input: PuzzleRunGetInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let mut run = module_puzzle::resolve_puzzle_run_timer_at( deserialize_run(&row.snapshot_json)?, micros_to_millis(now_micros), ); refresh_next_level_handoff(ctx, &mut run)?; if serialize_json(&run) != row.snapshot_json { replace_puzzle_runtime_run(ctx, &row, &run, now_micros); } if let Some((profile_id, grid_size)) = run .current_level .as_ref() .map(|level| (level.profile_id.clone(), level.grid_size)) { hydrate_puzzle_leaderboard_entries( ctx, &mut run, &input.owner_user_id, &profile_id, grid_size, ); } Ok(run) } fn swap_puzzle_pieces_tx( ctx: &TxContext, input: PuzzleRunSwapInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; let mut next_run = module_puzzle::swap_pieces_at( ¤t_run, &input.first_piece_id, &input.second_piece_id, micros_to_millis(input.swapped_at_micros), ) .map_err(|error| error.to_string())?; refresh_next_level_handoff(ctx, &mut next_run)?; if let Some((profile_id, grid_size)) = next_run .current_level .as_ref() .map(|level| (level.profile_id.clone(), level.grid_size)) { hydrate_puzzle_leaderboard_entries( ctx, &mut next_run, &input.owner_user_id, &profile_id, grid_size, ); } replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros); Ok(next_run) } fn drag_puzzle_piece_or_group_tx( ctx: &TxContext, input: PuzzleRunDragInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; let mut next_run = module_puzzle::drag_piece_or_group_at( ¤t_run, &input.piece_id, input.target_row, input.target_col, micros_to_millis(input.dragged_at_micros), ) .map_err(|error| error.to_string())?; refresh_next_level_handoff(ctx, &mut next_run)?; if let Some((profile_id, grid_size)) = next_run .current_level .as_ref() .map(|level| (level.profile_id.clone(), level.grid_size)) { hydrate_puzzle_leaderboard_entries( ctx, &mut next_run, &input.owner_user_id, &profile_id, grid_size, ); } replace_puzzle_runtime_run(ctx, &row, &next_run, input.dragged_at_micros); Ok(next_run) } fn advance_puzzle_next_level_tx( ctx: &TxContext, input: PuzzleRunNextLevelInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; let current_level = current_run .current_level .as_ref() .ok_or_else(|| "拼图关卡不存在".to_string())?; if current_level.status != PuzzleRuntimeLevelStatus::Cleared { return Err("当前关卡尚未通关".to_string()); } let current_profile_row = ctx .db .puzzle_work_profile() .profile_id() .find(¤t_level.profile_id) .ok_or_else(|| "当前拼图作品不存在".to_string())?; let current_profile = build_puzzle_work_profile_from_row(¤t_profile_row)?; let same_work_next_profile = selected_profile_level_after_runtime_level(¤t_profile, current_level) .map(|level| profile_for_single_level(¤t_profile, &level)); let candidates = if same_work_next_profile.is_none() { list_published_puzzle_profiles(ctx)? } else { Vec::new() }; let similar_work_next_profile = if same_work_next_profile.is_none() { let selected_candidates = select_next_profiles( ¤t_profile, ¤t_run.played_profile_ids, &candidates, 3, ); Some( if let Some(target_profile_id) = input.target_profile_id.as_ref().and_then(|value| { let trimmed = value.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) }) { selected_candidates .into_iter() .find(|candidate| candidate.profile_id == target_profile_id) .cloned() .ok_or_else(|| "目标拼图作品不在当前下一关候选中".to_string())? } else { selected_candidates .into_iter() .next() .cloned() .ok_or_else(|| "没有可用的下一关候选".to_string())? }, ) } else { None }; let next_profile = same_work_next_profile .as_ref() .or(similar_work_next_profile.as_ref()) .ok_or_else(|| "没有可用的下一关候选".to_string())?; let mut next_run = if same_work_next_profile.is_some() { module_puzzle::advance_next_level_at( ¤t_run, next_profile, micros_to_millis(input.advanced_at_micros), ) } else { module_puzzle::advance_to_new_work_first_level_at( ¤t_run, next_profile, micros_to_millis(input.advanced_at_micros), ) } .map_err(|error| error.to_string())?; let next_grid_size = next_run.current_grid_size; let next_profile_id = next_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( ctx, &mut next_run, &input.owner_user_id, &next_profile_id, next_grid_size, ); refresh_next_level_handoff(ctx, &mut next_run)?; if let Some(next_profile_row) = ctx .db .puzzle_work_profile() .profile_id() .find(&next_profile.profile_id) { record_public_work_play( ctx, PublicWorkPlayRecordInput { source_type: "puzzle".to_string(), owner_user_id: next_profile_row.owner_user_id.clone(), profile_id: next_profile_row.profile_id.clone(), played_at_micros: input.advanced_at_micros, }, )?; increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros); upsert_puzzle_profile_played_work( ctx, &input.owner_user_id, &next_profile_row, input.advanced_at_micros, )?; } replace_puzzle_runtime_run(ctx, &row, &next_run, input.advanced_at_micros); Ok(next_run) } fn update_puzzle_run_pause_tx( ctx: &TxContext, input: PuzzleRunPauseInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; let next_run = module_puzzle::set_puzzle_run_paused_at( ¤t_run, input.paused, micros_to_millis(input.updated_at_micros), ) .map_err(|error| error.to_string())?; let mut hydrated_run = next_run; refresh_next_level_handoff(ctx, &mut hydrated_run)?; replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.updated_at_micros); if let Some((profile_id, grid_size)) = hydrated_run .current_level .as_ref() .map(|level| (level.profile_id.clone(), level.grid_size)) { hydrate_puzzle_leaderboard_entries( ctx, &mut hydrated_run, &input.owner_user_id, &profile_id, grid_size, ); } Ok(hydrated_run) } fn use_puzzle_runtime_prop_tx( ctx: &TxContext, input: PuzzleRunPropInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; let next_run = match input.prop_kind.as_str() { "freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at( ¤t_run, micros_to_millis(input.used_at_micros), ) .map_err(|error| error.to_string())?, "extendTime" | "extend_time" => module_puzzle::extend_failed_puzzle_time_at( ¤t_run, micros_to_millis(input.used_at_micros), ) .map_err(|error| error.to_string())?, "hint" => module_puzzle::set_puzzle_run_paused_at( ¤t_run, false, micros_to_millis(input.used_at_micros), ) .map_err(|error| error.to_string())?, "reference" => module_puzzle::set_puzzle_run_paused_at( ¤t_run, true, micros_to_millis(input.used_at_micros), ) .map_err(|error| error.to_string())?, _ => return Err("未知拼图道具".to_string()), }; let mut hydrated_run = next_run; refresh_next_level_handoff(ctx, &mut hydrated_run)?; if let Some(profile_id) = hydrated_run .current_level .as_ref() .map(|level| level.profile_id.clone()) { accrue_puzzle_point_incentive( ctx, &profile_id, &input.owner_user_id, input.spent_points, input.used_at_micros, )?; } replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.used_at_micros); if let Some((profile_id, grid_size)) = hydrated_run .current_level .as_ref() .map(|level| (level.profile_id.clone(), level.grid_size)) { hydrate_puzzle_leaderboard_entries( ctx, &mut hydrated_run, &input.owner_user_id, &profile_id, grid_size, ); } Ok(hydrated_run) } fn claim_puzzle_work_point_incentive_tx( ctx: &TxContext, input: PuzzleWorkPointIncentiveClaimInput, ) -> Result { let profile_id = input.profile_id.trim(); let owner_user_id = input.owner_user_id.trim(); if profile_id.is_empty() || owner_user_id.is_empty() { return Err("拼图积分激励参数不能为空".to_string()); } let row = ctx .db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "拼图作品不存在".to_string())?; if row.owner_user_id != owner_user_id { return Err("无权领取该作品的积分激励".to_string()); } let claimable_points = module_puzzle::puzzle_point_incentive_claimable_points( row.point_incentive_total_half_points, row.point_incentive_claimed_points, ); if claimable_points == 0 { return Err("暂无可领取积分激励".to_string()); } let claimed_at = Timestamp::from_micros_since_unix_epoch(input.claimed_at_micros); let next_row = PuzzleWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags_json: row.theme_tags_json.clone(), cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), levels_json: row.levels_json.clone(), publication_status: row.publication_status, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, point_incentive_total_half_points: row.point_incentive_total_half_points, point_incentive_claimed_points: row .point_incentive_claimed_points .saturating_add(claimable_points), anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, updated_at: claimed_at, published_at: row.published_at, }; replace_puzzle_work_profile(ctx, &row, next_row); grant_profile_wallet_points( ctx, owner_user_id, claimable_points, RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim, &format!( "puzzle_author_incentive_claim:{}:{}:{}", profile_id, owner_user_id, input.claimed_at_micros ), claimed_at, )?; let updated = ctx .db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "拼图积分激励领取更新失败".to_string())?; build_puzzle_work_profile_from_row(&updated) } fn submit_puzzle_leaderboard_entry_tx( ctx: &TxContext, input: PuzzleLeaderboardSubmitInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let mut run = deserialize_run(&row.snapshot_json)?; let current_level = run .current_level .as_ref() .ok_or_else(|| "拼图关卡不存在".to_string())?; if input.profile_id.trim().is_empty() { return Err("提交成绩的拼图作品不能为空".to_string()); } if !module_puzzle::is_supported_puzzle_grid_size(input.grid_size) { return Err("提交成绩的网格规格无效".to_string()); } let matches_service_level = current_level.profile_id == input.profile_id && current_level.grid_size == input.grid_size; if current_level.profile_id == input.profile_id && current_level.grid_size != input.grid_size { return Err("提交成绩的网格规格与当前关卡不匹配".to_string()); } let current_profile_row = ctx .db .puzzle_work_profile() .profile_id() .find(&input.profile_id) .ok_or_else(|| "提交成绩的拼图作品不存在".to_string())?; if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) { return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); } if current_profile_row.publication_status != PuzzlePublicationStatus::Published { hydrate_puzzle_leaderboard_entries( ctx, &mut run, &input.owner_user_id, &input.profile_id, input.grid_size, ); replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros); return Ok(run); } let nickname = input.nickname.trim(); if nickname.is_empty() { return Err("排行榜昵称不能为空".to_string()); } upsert_puzzle_leaderboard_entry( ctx, &input.owner_user_id, &input.profile_id, input.grid_size, nickname, input.elapsed_ms.max(1_000), &input.run_id, input.submitted_at_micros, ); add_profile_observed_play_time( ctx, &input.owner_user_id, &format!("puzzle:{}", input.profile_id), input.elapsed_ms.max(1_000), input.submitted_at_micros, )?; let leaderboard_entries = list_puzzle_leaderboard_entries( ctx, &input.profile_id, input.grid_size, &input.owner_user_id, 10, ); if matches_service_level { if let Some(level) = run.current_level.as_mut() { // 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。 // 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。 level.status = PuzzleRuntimeLevelStatus::Cleared; level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros)); level.elapsed_ms = Some(input.elapsed_ms.max(1_000)); level.leaderboard_entries = leaderboard_entries.clone(); } run.cleared_level_count = run.cleared_level_count.max(run.current_level_index); } else { // 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。 // 前端通过 local-next-level 推进到第二关后,服务端旧 run 可能仍停在上一关。 // 此时只返回真实榜单,前端会把榜单合并回当前本地关卡,不能用旧棋盘覆盖前端状态。 log::info!( "puzzle leaderboard submitted for frontend-only level: run_id={}, service_profile_id={}, submitted_profile_id={}", input.run_id, current_level.profile_id, input.profile_id ); } run.leaderboard_entries = leaderboard_entries; refresh_next_level_handoff(ctx, &mut run)?; replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros); Ok(run) } fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str) -> bool { run.recommended_next_profile_id .as_ref() .is_some_and(|candidate_profile_id| candidate_profile_id == profile_id) || run .next_level_profile_id .as_ref() .is_some_and(|candidate_profile_id| candidate_profile_id == profile_id) || run .recommended_next_works .iter() .any(|candidate| candidate.profile_id == profile_id) || run .played_profile_ids .iter() .any(|played_profile_id| played_profile_id == profile_id) } fn build_puzzle_agent_session_snapshot( ctx: &TxContext, row: &PuzzleAgentSessionRow, ) -> Result { let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?; let draft = deserialize_optional_draft(&row.draft_json)?; let messages = list_session_messages(ctx, &row.session_id); let result_preview = draft .as_ref() .map(|value| build_result_preview(value, Some("百梦主"))); Ok(PuzzleAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage, anchor_pack, draft, messages, last_assistant_reply: row.last_assistant_reply.clone(), published_profile_id: row.published_profile_id.clone(), suggested_actions: build_puzzle_suggested_actions(row.stage), result_preview, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) } fn build_puzzle_work_profile_from_row( row: &PuzzleWorkProfileRow, ) -> Result { build_puzzle_work_profile_from_row_without_recent_count(row) } fn build_puzzle_work_profile_from_row_with_recent_count( ctx: &TxContext, row: &PuzzleWorkProfileRow, now_micros: i64, ) -> Result { let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?; profile.recent_play_count_7d = count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros); Ok(profile) } fn build_puzzle_work_profile_from_row_without_recent_count( row: &PuzzleWorkProfileRow, ) -> Result { Ok(PuzzleWorkProfile { work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: if row.work_title.trim().is_empty() { row.level_name.clone() } else { row.work_title.clone() }, work_description: if row.work_description.trim().is_empty() { row.summary.clone() } else { row.work_description.clone() }, level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags: deserialize_theme_tags(&row.theme_tags_json)?, cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), levels: build_profile_levels_from_row(row)?, publication_status: row.publication_status, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, point_incentive_total_half_points: row.point_incentive_total_half_points, point_incentive_claimed_points: row.point_incentive_claimed_points, recent_play_count_7d: 0, publish_ready: row.publish_ready, anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, }) } fn build_profile_levels_from_row( row: &PuzzleWorkProfileRow, ) -> Result, String> { let levels = deserialize_levels_json(&row.levels_json)?; if !levels.is_empty() { return Ok(levels); } Ok(vec![module_puzzle::PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: row.level_name.clone(), picture_description: row.summary.clone(), candidates: Vec::new(), selected_candidate_id: None, cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), generation_status: if row.cover_image_src.is_some() { "ready".to_string() } else { "idle".to_string() }, }]) } fn selected_profile_level( profile: &PuzzleWorkProfile, level_id: Option<&str>, ) -> Result, String> { let Some(level_id) = level_id.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) else { return Ok(None); }; profile .levels .iter() .find(|level| level.level_id == level_id) .cloned() .map(Some) .ok_or_else(|| "入口拼图关卡不存在".to_string()) } fn profile_for_single_level( profile: &PuzzleWorkProfile, level: &module_puzzle::PuzzleDraftLevel, ) -> PuzzleWorkProfile { let mut next_profile = profile.clone(); next_profile.level_name = level.level_name.clone(); next_profile.cover_image_src = level.cover_image_src.clone(); next_profile.cover_asset_id = level.cover_asset_id.clone(); next_profile.levels = vec![level.clone()]; next_profile } fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) { let stable_suffix = session_id .strip_prefix("puzzle-session-") .unwrap_or(session_id); ( format!("puzzle-work-{stable_suffix}"), format!("puzzle-profile-{stable_suffix}"), ) } fn micros_to_millis(value: i64) -> u64 { if value <= 0 { return 0; } (value as u64).saturating_div(1_000) } fn upsert_puzzle_draft_work_profile( ctx: &TxContext, session_id: &str, owner_user_id: &str, draft: &PuzzleResultDraft, updated_at_micros: i64, ) -> Result<(), String> { let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(session_id); if let Some(existing) = ctx.db.puzzle_work_profile().profile_id().find(&profile_id) { if existing.publication_status == PuzzlePublicationStatus::Published { return Ok(()); } let mut profile = create_work_profile( work_id, profile_id, owner_user_id.to_string(), Some(session_id.to_string()), existing.author_display_name.clone(), draft, updated_at_micros, ) .map_err(|error| error.to_string())?; profile.play_count = existing.play_count; profile.remix_count = existing.remix_count; profile.like_count = existing.like_count; profile.point_incentive_total_half_points = existing.point_incentive_total_half_points; profile.point_incentive_claimed_points = existing.point_incentive_claimed_points; return upsert_puzzle_work_profile(ctx, profile); } let profile = create_work_profile( work_id, profile_id, owner_user_id.to_string(), Some(session_id.to_string()), "百梦主".to_string(), draft, updated_at_micros, ) .map_err(|error| error.to_string())?; upsert_puzzle_work_profile(ctx, profile) } fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec { let mut items = ctx .db .puzzle_agent_message() .iter() .filter(|message| message.session_id == session_id) .map(|message| PuzzleAgentMessageSnapshot { message_id: message.message_id.clone(), session_id: message.session_id.clone(), role: message.role, kind: message.kind, text: message.text.clone(), created_at_micros: message.created_at.to_micros_since_unix_epoch(), }) .collect::>(); items.sort_by(|left, right| left.created_at_micros.cmp(&right.created_at_micros)); items } fn build_puzzle_suggested_actions( stage: PuzzleAgentStage, ) -> Vec { match stage { PuzzleAgentStage::CollectingAnchors => vec![module_puzzle::PuzzleAgentSuggestedAction { id: "compile-draft".to_string(), action_type: "compile_puzzle_draft".to_string(), label: "进入结果页".to_string(), }], PuzzleAgentStage::DraftReady | PuzzleAgentStage::ImageRefining => vec![ module_puzzle::PuzzleAgentSuggestedAction { id: "generate-images".to_string(), action_type: "generate_puzzle_images".to_string(), label: "生成候选图".to_string(), }, module_puzzle::PuzzleAgentSuggestedAction { id: "publish-work".to_string(), action_type: "publish_puzzle_work".to_string(), label: "发布作品".to_string(), }, ], PuzzleAgentStage::ReadyToPublish => vec![module_puzzle::PuzzleAgentSuggestedAction { id: "publish-work".to_string(), action_type: "publish_puzzle_work".to_string(), label: "发布作品".to_string(), }], PuzzleAgentStage::Published => Vec::new(), } } fn ensure_session_missing(ctx: &TxContext, session_id: &str) -> Result<(), String> { if ctx .db .puzzle_agent_session() .session_id() .find(&session_id.to_string()) .is_some() { return Err("拼图 session 已存在".to_string()); } Ok(()) } fn ensure_message_missing(ctx: &TxContext, message_id: &str) -> Result<(), String> { if ctx .db .puzzle_agent_message() .message_id() .find(&message_id.to_string()) .is_some() { return Err("拼图消息已存在".to_string()); } Ok(()) } fn get_owned_session_row( ctx: &TxContext, session_id: &str, owner_user_id: &str, ) -> Result { let row = ctx .db .puzzle_agent_session() .session_id() .find(&session_id.to_string()) .ok_or_else(|| "拼图 session 不存在".to_string())?; if row.owner_user_id != owner_user_id { return Err("无权访问该拼图 session".to_string()); } Ok(row) } fn get_owned_run_row( ctx: &TxContext, run_id: &str, owner_user_id: &str, ) -> Result { let row = ctx .db .puzzle_runtime_run() .run_id() .find(&run_id.to_string()) .ok_or_else(|| "拼图 run 不存在".to_string())?; if row.owner_user_id != owner_user_id { return Err("无权访问该拼图 run".to_string()); } Ok(row) } fn replace_puzzle_agent_session( ctx: &TxContext, current: &PuzzleAgentSessionRow, next: PuzzleAgentSessionRow, ) { ctx.db .puzzle_agent_session() .session_id() .delete(¤t.session_id); ctx.db.puzzle_agent_session().insert(next); } fn replace_puzzle_work_profile( ctx: &TxContext, current: &PuzzleWorkProfileRow, next: PuzzleWorkProfileRow, ) { ctx.db .puzzle_work_profile() .profile_id() .delete(¤t.profile_id); ctx.db.puzzle_work_profile().insert(next); } fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> { if let Some(existing) = ctx .db .puzzle_work_profile() .profile_id() .find(&profile.profile_id) { replace_puzzle_work_profile( ctx, &existing, PuzzleWorkProfileRow { profile_id: profile.profile_id, work_id: profile.work_id, owner_user_id: profile.owner_user_id, source_session_id: profile.source_session_id, author_display_name: profile.author_display_name, work_title: profile.work_title, work_description: profile.work_description, level_name: profile.level_name, summary: profile.summary, theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, levels_json: serialize_json(&profile.levels), publication_status: profile.publication_status, // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于 // 广场消费数据,不能因为重新发布被清零。 play_count: existing.play_count.max(profile.play_count), remix_count: existing.remix_count.max(profile.remix_count), like_count: existing.like_count.max(profile.like_count), point_incentive_total_half_points: existing .point_incentive_total_half_points .max(profile.point_incentive_total_half_points), point_incentive_claimed_points: existing .point_incentive_claimed_points .max(profile.point_incentive_claimed_points), anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros), published_at: profile .published_at_micros .map(Timestamp::from_micros_since_unix_epoch), }, ); return Ok(()); } ctx.db.puzzle_work_profile().insert(PuzzleWorkProfileRow { profile_id: profile.profile_id, work_id: profile.work_id, owner_user_id: profile.owner_user_id, source_session_id: profile.source_session_id, author_display_name: profile.author_display_name, work_title: profile.work_title, work_description: profile.work_description, level_name: profile.level_name, summary: profile.summary, theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, levels_json: serialize_json(&profile.levels), publication_status: profile.publication_status, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, point_incentive_total_half_points: profile.point_incentive_total_half_points, point_incentive_claimed_points: profile.point_incentive_claimed_points, anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros), published_at: profile .published_at_micros .map(Timestamp::from_micros_since_unix_epoch), }); Ok(()) } fn emit_puzzle_work_published_event( ctx: &TxContext, profile: &PuzzleWorkProfile, occurred_at_micros: i64, ) { ctx.db.puzzle_event().insert(PuzzleEvent { event_id: format!( "pzevt_{}_{}_published", profile.profile_id, occurred_at_micros ), profile_id: profile.profile_id.clone(), work_id: profile.work_id.clone(), session_id: profile.source_session_id.clone(), owner_user_id: profile.owner_user_id.clone(), event_kind: PuzzleEventKind::WorkPublished, occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_micros), }); } fn insert_puzzle_runtime_run( ctx: &TxContext, run: &PuzzleRunSnapshot, owner_user_id: &str, created_at_micros: i64, ) -> Result<(), String> { let timestamp = Timestamp::from_micros_since_unix_epoch(created_at_micros); let current_profile_id = run .current_level .as_ref() .map(|level| level.profile_id.clone()) .ok_or_else(|| "拼图关卡不存在".to_string())?; ctx.db.puzzle_runtime_run().insert(PuzzleRuntimeRunRow { run_id: run.run_id.clone(), owner_user_id: owner_user_id.to_string(), entry_profile_id: run.entry_profile_id.clone(), current_profile_id, cleared_level_count: run.cleared_level_count, current_level_index: run.current_level_index, current_grid_size: run.current_grid_size, played_profile_ids_json: serialize_json(&run.played_profile_ids), previous_level_tags_json: serialize_json(&run.previous_level_tags), snapshot_json: serialize_json(run), created_at: timestamp, updated_at: timestamp, }); upsert_puzzle_profile_save_archive(ctx, run, owner_user_id, created_at_micros)?; Ok(()) } fn replace_puzzle_runtime_run( ctx: &TxContext, current: &PuzzleRuntimeRunRow, run: &PuzzleRunSnapshot, updated_at_micros: i64, ) { ctx.db.puzzle_runtime_run().run_id().delete(¤t.run_id); ctx.db.puzzle_runtime_run().insert(PuzzleRuntimeRunRow { run_id: run.run_id.clone(), owner_user_id: current.owner_user_id.clone(), entry_profile_id: run.entry_profile_id.clone(), current_profile_id: run .current_level .as_ref() .map(|level| level.profile_id.clone()) .unwrap_or_else(|| current.current_profile_id.clone()), cleared_level_count: run.cleared_level_count, current_level_index: run.current_level_index, current_grid_size: run.current_grid_size, played_profile_ids_json: serialize_json(&run.played_profile_ids), previous_level_tags_json: serialize_json(&run.previous_level_tags), snapshot_json: serialize_json(run), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); if let Err(error) = upsert_puzzle_profile_save_archive(ctx, run, ¤t.owner_user_id, updated_at_micros) { log::warn!("拼图存档投影同步失败: {}", error); } } fn upsert_puzzle_profile_save_archive( ctx: &TxContext, run: &PuzzleRunSnapshot, user_id: &str, saved_at_micros: i64, ) -> Result<(), String> { let user_id = user_id.trim(); if user_id.is_empty() { return Ok(()); } let Some(current_level) = run.current_level.as_ref() else { return Ok(()); }; let world_key = format!("puzzle:{}", run.entry_profile_id); let target = resolve_puzzle_archive_target(ctx, run, current_level)?; let work_title = resolve_puzzle_archive_work_title(ctx, &target.profile_id, &target.level_name); let subtitle = build_puzzle_archive_subtitle(target.level_index, &target.level_name); // 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。 let game_state_json = json_to_string(&json!({ "runtimeKind": "puzzle", "runId": run.run_id, "entryProfileId": run.entry_profile_id, "currentProfileId": target.profile_id.clone(), "currentLevelIndex": target.level_index, "currentLevelId": target.level_id.clone(), "status": target.status.as_str(), })) .unwrap_or_else(|_| "{}".to_string()); upsert_profile_save_archive( ctx, ProfileSaveArchiveUpsertInput { user_id: user_id.to_string(), world_key, owner_user_id: target.owner_user_id, profile_id: Some(run.entry_profile_id.clone()), world_type: Some("PUZZLE".to_string()), world_name: work_title, subtitle, summary_text: puzzle_archive_summary_text(target.status), cover_image_src: target.cover_image_src, bottom_tab: "puzzle".to_string(), game_state_json, current_story_json: None, saved_at_micros, }, ) } struct PuzzleArchiveTarget { profile_id: String, level_index: u32, level_id: Option, level_name: String, status: PuzzleRuntimeLevelStatus, cover_image_src: Option, owner_user_id: Option, } fn resolve_puzzle_archive_target( ctx: &TxContext, run: &PuzzleRunSnapshot, current_level: &module_puzzle::PuzzleRuntimeLevelSnapshot, ) -> Result { // 中文注释:通关后若已经算出同作品下一关,存档页直接投影到下一关入口; // 跨作品候选需要玩家选择,不能在存档里提前替玩家切换作品。 let owner_user_id = resolve_puzzle_current_owner_user_id(ctx, ¤t_level.profile_id); if current_level.status != PuzzleRuntimeLevelStatus::Cleared { return Ok(PuzzleArchiveTarget { profile_id: current_level.profile_id.clone(), level_index: current_level.level_index, level_id: current_level.level_id.clone(), level_name: current_level.level_name.clone(), status: current_level.status, cover_image_src: current_level.cover_image_src.clone(), owner_user_id, }); } let Some(next_level_id) = run .next_level_id .as_deref() .filter(|value| !value.trim().is_empty()) else { return Ok(PuzzleArchiveTarget { profile_id: current_level.profile_id.clone(), level_index: current_level.level_index, level_id: current_level.level_id.clone(), level_name: current_level.level_name.clone(), status: current_level.status, cover_image_src: current_level.cover_image_src.clone(), owner_user_id, }); }; if run.next_level_profile_id.as_deref() != Some(current_level.profile_id.as_str()) || run.next_level_mode != PUZZLE_NEXT_LEVEL_MODE_SAME_WORK { return Ok(PuzzleArchiveTarget { profile_id: current_level.profile_id.clone(), level_index: current_level.level_index, level_id: current_level.level_id.clone(), level_name: current_level.level_name.clone(), status: current_level.status, cover_image_src: current_level.cover_image_src.clone(), owner_user_id, }); } let current_profile = build_puzzle_work_profile_from_row( &ctx.db .puzzle_work_profile() .profile_id() .find(¤t_level.profile_id) .ok_or_else(|| "当前拼图作品不存在".to_string())?, )?; let next_level = current_profile .levels .iter() .find(|level| level.level_id == next_level_id) .cloned() .ok_or_else(|| "下一关拼图关卡不存在".to_string())?; Ok(PuzzleArchiveTarget { profile_id: current_profile.profile_id, level_index: current_level.level_index.saturating_add(1), level_id: Some(next_level.level_id), level_name: next_level.level_name, status: PuzzleRuntimeLevelStatus::Playing, cover_image_src: next_level.cover_image_src, owner_user_id, }) } fn resolve_puzzle_archive_work_title( ctx: &TxContext, profile_id: &str, fallback_level_name: &str, ) -> String { // 中文注释:存档主标题必须是作品名;历史数据或异常行缺失作品名时才回退到关卡名。 ctx.db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .map(|row| { let title = row.work_title.trim(); if title.is_empty() { fallback_level_name.to_string() } else { title.to_string() } }) .unwrap_or_else(|| fallback_level_name.to_string()) } fn build_puzzle_archive_subtitle(level_index: u32, level_name: &str) -> String { let level_label = format!("第 {level_index} 关"); let level_name = level_name.trim(); if level_name.is_empty() { level_label } else { format!("{level_label} · {level_name}") } } fn resolve_puzzle_current_owner_user_id(ctx: &TxContext, profile_id: &str) -> Option { ctx.db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .map(|row| row.owner_user_id) } fn puzzle_archive_summary_text(status: PuzzleRuntimeLevelStatus) -> String { match status { PuzzleRuntimeLevelStatus::Cleared => "关卡已完成", PuzzleRuntimeLevelStatus::Failed => "关卡失败", PuzzleRuntimeLevelStatus::Playing => "拼图进行中", } .to_string() } fn accrue_puzzle_point_incentive( ctx: &TxContext, profile_id: &str, player_user_id: &str, spent_points: u64, updated_at_micros: i64, ) -> Result<(), String> { if spent_points == 0 { return Ok(()); } let Some(row) = ctx .db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) else { return Ok(()); }; if row.publication_status != PuzzlePublicationStatus::Published || row.owner_user_id == player_user_id { return Ok(()); } replace_puzzle_work_profile( ctx, &row, PuzzleWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags_json: row.theme_tags_json.clone(), cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), levels_json: row.levels_json.clone(), publication_status: row.publication_status, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, point_incentive_total_half_points: module_puzzle::puzzle_point_incentive_total_after_spend( row.point_incentive_total_half_points, spent_points, ), point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), published_at: row.published_at, }, ); Ok(()) } fn increment_puzzle_profile_play_count( ctx: &TxContext, row: &PuzzleWorkProfileRow, updated_at_micros: i64, ) { replace_puzzle_work_profile( ctx, row, PuzzleWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags_json: row.theme_tags_json.clone(), cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), levels_json: row.levels_json.clone(), publication_status: row.publication_status, play_count: row.play_count.saturating_add(1), remix_count: row.remix_count, like_count: row.like_count, point_incentive_total_half_points: row.point_incentive_total_half_points, point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), published_at: row.published_at, }, ); } fn upsert_puzzle_profile_played_work( ctx: &TxContext, user_id: &str, row: &PuzzleWorkProfileRow, played_at_micros: i64, ) -> Result<(), String> { // 拼图正式游玩以作品 profile_id 作为公开作品号,用户侧明细按 world_key 去重。 upsert_profile_played_work( ctx, ProfilePlayedWorkUpsertInput { user_id: user_id.to_string(), world_key: format!("puzzle:{}", row.profile_id), owner_user_id: Some(row.owner_user_id.clone()), profile_id: Some(row.profile_id.clone()), world_type: Some("PUZZLE".to_string()), world_title: row.level_name.clone(), world_subtitle: row.summary.clone(), played_at_micros, }, ) } fn replace_generated_candidate( candidates_slot: &mut Vec, candidates: Vec, ) { // 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。 *candidates_slot = candidates .into_iter() .take(1) .map(|mut candidate| { candidate.selected = true; candidate }) .collect(); } fn list_published_puzzle_profiles(ctx: &TxContext) -> Result, String> { ctx.db .puzzle_work_profile() .iter() .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) .map(|row| build_puzzle_work_profile_from_row(&row)) .collect() } fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) { run.recommended_next_profile_id = None; run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(); run.next_level_profile_id = None; run.next_level_id = None; run.recommended_next_works = Vec::new(); } fn build_recommended_next_work( current_profile: &PuzzleWorkProfile, candidate: &PuzzleWorkProfile, ) -> PuzzleRecommendedNextWork { PuzzleRecommendedNextWork { profile_id: candidate.profile_id.clone(), level_name: candidate.level_name.clone(), author_display_name: candidate.author_display_name.clone(), theme_tags: candidate.theme_tags.clone(), cover_image_src: candidate.cover_image_src.clone(), similarity_score: tag_similarity_score(¤t_profile.theme_tags, &candidate.theme_tags), } } fn refresh_next_level_handoff(ctx: &TxContext, run: &mut PuzzleRunSnapshot) -> Result<(), String> { let current_level = match run.current_level.as_ref() { Some(value) => value, None => { reset_next_level_handoff(run); return Ok(()); } }; let current_profile = build_puzzle_work_profile_from_row( &ctx.db .puzzle_work_profile() .profile_id() .find(¤t_level.profile_id) .ok_or_else(|| "当前拼图作品不存在".to_string())?, )?; if current_level.status != PuzzleRuntimeLevelStatus::Cleared { reset_next_level_handoff(run); return Ok(()); } if let Some(next_level) = selected_profile_level_after_runtime_level(¤t_profile, current_level) { run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string(); run.next_level_profile_id = Some(current_profile.profile_id.clone()); run.next_level_id = Some(next_level.level_id); run.recommended_next_profile_id = Some(current_profile.profile_id.clone()); run.recommended_next_works = Vec::new(); return Ok(()); } let candidates = list_published_puzzle_profiles(ctx)?; let recommended_next_works = select_next_profiles(¤t_profile, &run.played_profile_ids, &candidates, 3) .into_iter() .map(|candidate| build_recommended_next_work(¤t_profile, candidate)) .collect::>(); if recommended_next_works.is_empty() { reset_next_level_handoff(run); return Ok(()); } run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string(); run.next_level_profile_id = recommended_next_works .first() .map(|candidate| candidate.profile_id.clone()); run.next_level_id = None; run.recommended_next_profile_id = run.next_level_profile_id.clone(); run.recommended_next_works = recommended_next_works; Ok(()) } fn hydrate_puzzle_leaderboard_entries( ctx: &TxContext, run: &mut PuzzleRunSnapshot, current_user_id: &str, profile_id: &str, grid_size: u32, ) { let leaderboard_entries = list_puzzle_leaderboard_entries(ctx, profile_id, grid_size, current_user_id, 10); run.leaderboard_entries = leaderboard_entries.clone(); if let Some(level) = run.current_level.as_mut() { level.leaderboard_entries = leaderboard_entries; } } fn build_puzzle_leaderboard_entry_id(user_id: &str, profile_id: &str, grid_size: u32) -> String { format!("puzzle-leaderboard-{user_id}-{profile_id}-{grid_size}") } fn upsert_puzzle_leaderboard_entry( ctx: &TxContext, user_id: &str, profile_id: &str, grid_size: u32, nickname: &str, elapsed_ms: u64, run_id: &str, updated_at_micros: i64, ) { let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size); let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) { let should_replace = elapsed_ms < existing.best_elapsed_ms || (elapsed_ms == existing.best_elapsed_ms && updated_at.to_micros_since_unix_epoch() < existing.updated_at.to_micros_since_unix_epoch()); let next_row = PuzzleLeaderboardEntryRow { entry_id: existing.entry_id.clone(), profile_id: existing.profile_id.clone(), grid_size: existing.grid_size, user_id: existing.user_id.clone(), nickname: nickname.to_string(), best_elapsed_ms: if should_replace { elapsed_ms } else { existing.best_elapsed_ms }, last_run_id: if should_replace { run_id.to_string() } else { existing.last_run_id.clone() }, updated_at, }; ctx.db .puzzle_leaderboard_entry() .entry_id() .delete(&existing.entry_id); ctx.db.puzzle_leaderboard_entry().insert(next_row); return; } ctx.db .puzzle_leaderboard_entry() .insert(PuzzleLeaderboardEntryRow { entry_id, profile_id: profile_id.to_string(), grid_size, user_id: user_id.to_string(), nickname: nickname.to_string(), best_elapsed_ms: elapsed_ms, last_run_id: run_id.to_string(), updated_at, }); } fn list_puzzle_leaderboard_entries( ctx: &TxContext, profile_id: &str, grid_size: u32, current_user_id: &str, limit: usize, ) -> Vec { let mut rows = ctx .db .puzzle_leaderboard_entry() .iter() .filter(|row| row.profile_id == profile_id && row.grid_size == grid_size) .collect::>(); rows.sort_by(|left, right| { left.best_elapsed_ms .cmp(&right.best_elapsed_ms) .then_with(|| left.updated_at.cmp(&right.updated_at)) .then_with(|| left.user_id.cmp(&right.user_id)) }); rows.into_iter() .take(limit) .enumerate() .map(|(index, row)| PuzzleLeaderboardEntry { rank: index as u32 + 1, nickname: row.nickname, elapsed_ms: row.best_elapsed_ms, is_current_player: row.user_id == current_user_id, }) .collect() } fn serialize_json(value: &T) -> String { json_to_string(value).unwrap_or_else(|_| "{}".to_string()) } fn deserialize_anchor_pack(value: &str) -> Result { json_from_str(value).map_err(|error| format!("拼图 anchor_pack JSON 非法: {error}")) } fn deserialize_optional_draft(value: &Option) -> Result, String> { value .as_ref() .map(|raw| { json_from_str(raw) .map(normalize_puzzle_draft) .map_err(|error| format!("拼图 draft JSON 非法: {error}")) }) .transpose() } fn deserialize_draft_required(value: &Option) -> Result { deserialize_optional_draft(value)?.ok_or_else(|| "拼图 draft 尚未生成".to_string()) } fn deserialize_theme_tags(value: &str) -> Result, String> { json_from_str(value).map_err(|error| format!("拼图 theme_tags JSON 非法: {error}")) } fn deserialize_levels_json(value: &str) -> Result, String> { if value.trim().is_empty() { return Ok(Vec::new()); } json_from_str(value).map_err(|error| format!("拼图 levels JSON 非法: {error}")) } fn deserialize_optional_levels_input( value: Option<&str>, ) -> Result>, String> { value .map(|raw| deserialize_levels_json(raw)) .transpose() .map(|levels| levels.filter(|items| !items.is_empty())) } fn deserialize_run(value: &str) -> Result { json_from_str(value).map_err(|error| format!("拼图 run snapshot JSON 非法: {error}")) } #[cfg(test)] mod tests { use super::*; use module_puzzle::{ PuzzleLeaderboardEntry, build_generated_candidates, compile_result_draft, empty_anchor_pack, recommendation_score, tag_similarity_score, }; #[test] fn puzzle_json_round_trip_keeps_snapshot_shape() { let snapshot = PuzzleRunSnapshot { run_id: "run-1".to_string(), entry_profile_id: "profile-1".to_string(), cleared_level_count: 0, current_level_index: 1, current_grid_size: 3, played_profile_ids: vec!["profile-1".to_string()], previous_level_tags: vec!["蒸汽城市".to_string()], current_level: None, recommended_next_profile_id: None, next_level_mode: PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(), next_level_profile_id: None, next_level_id: None, recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }; let serialized = serialize_json(&snapshot); let parsed = deserialize_run(&serialized).expect("run json should parse"); assert_eq!(parsed.run_id, "run-1"); } #[test] fn puzzle_preview_is_publishable_with_complete_draft() { let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪")); let draft = compile_result_draft(&anchor_pack, &[]); let candidates = build_generated_candidates("session-1", None, &draft, 2, 1_000_000) .expect("candidates should build"); let draft = apply_selected_candidate( PuzzleResultDraft { levels: vec![module_puzzle::PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: draft.level_name.clone(), picture_description: draft .levels .first() .map(|level| level.picture_description.clone()) .unwrap_or_default(), candidates: candidates.clone(), selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), }], candidates, ..draft }, "session-1-candidate-1", ) .expect("draft should select"); let preview = build_result_preview(&draft, Some("作者")); assert!(preview.publish_ready); } #[test] fn puzzle_generated_images_replace_existing_candidate() { let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪")); let mut draft = compile_result_draft(&anchor_pack, &[]); draft.candidates = vec![PuzzleGeneratedImageCandidate { candidate_id: "session-1-candidate-1".to_string(), image_src: "/generated-puzzle-assets/session-1/old/cover.png".to_string(), asset_id: "asset-old".to_string(), prompt: "旧提示词".to_string(), actual_prompt: Some("旧提示词".to_string()), source_type: "generated".to_string(), selected: true, }]; replace_generated_candidate( &mut draft.candidates, vec![PuzzleGeneratedImageCandidate { candidate_id: "session-1-candidate-2".to_string(), image_src: "/generated-puzzle-assets/session-1/new/cover.png".to_string(), asset_id: "asset-new".to_string(), prompt: "新提示词".to_string(), actual_prompt: Some("新提示词".to_string()), source_type: "generated".to_string(), selected: true, }], ); assert_eq!(draft.candidates.len(), 1); assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-2"); assert!(draft.candidates[0].selected); } #[test] fn generated_first_level_name_defaults_work_title_when_previous_title_is_fallback() { let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None); let mut draft = compile_result_draft_from_seed( &anchor_pack, &[], Some("画面描述:一只猫在雨夜灯牌下回头。"), ); let previous_level_name = draft.level_name.clone(); let previous_work_title = draft.work_title.clone(); draft.levels[0].level_name = "雨夜猫街".to_string(); module_puzzle::sync_primary_level_fields(&mut draft); sync_generated_primary_level_name_as_default_work_title( &mut draft, &previous_work_title, &previous_level_name, ); assert_eq!(draft.level_name, "雨夜猫街"); assert_eq!(draft.work_title, "雨夜猫街"); } #[test] fn generated_first_level_name_keeps_manual_work_title() { let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None); let mut draft = compile_result_draft_from_seed( &anchor_pack, &[], Some("画面描述:一只猫在雨夜灯牌下回头。"), ); let previous_level_name = draft.level_name.clone(); let previous_work_title = "我的猫街合集".to_string(); draft.work_title = previous_work_title.clone(); draft.levels[0].level_name = "雨夜猫街".to_string(); module_puzzle::sync_primary_level_fields(&mut draft); sync_generated_primary_level_name_as_default_work_title( &mut draft, &previous_work_title, &previous_level_name, ); assert_eq!(draft.level_name, "雨夜猫街"); assert_eq!(draft.work_title, "我的猫街合集"); } #[test] fn puzzle_recommendation_score_prefers_same_author_weight() { let left = PuzzleWorkProfile { work_id: "work-a".to_string(), profile_id: "profile-a".to_string(), owner_user_id: "owner-a".to_string(), source_session_id: None, author_display_name: "作者".to_string(), work_title: "A".to_string(), work_description: String::new(), level_name: "A".to_string(), summary: String::new(), theme_tags: vec!["雨夜".to_string(), "猫咪".to_string()], cover_image_src: Some("/a.png".to_string()), cover_asset_id: Some("asset-a".to_string()), levels: Vec::new(), publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 1, published_at_micros: Some(1), play_count: 0, recent_play_count_7d: 0, remix_count: 0, like_count: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), }; let right = PuzzleWorkProfile { owner_user_id: "owner-a".to_string(), profile_id: "profile-b".to_string(), work_id: "work-b".to_string(), work_title: "B".to_string(), work_description: String::new(), level_name: "B".to_string(), theme_tags: vec!["雨夜".to_string(), "蒸汽城市".to_string()], cover_image_src: Some("/b.png".to_string()), cover_asset_id: Some("asset-b".to_string()), levels: Vec::new(), publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 2, published_at_micros: Some(2), play_count: 0, recent_play_count_7d: 0, remix_count: 0, like_count: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), source_session_id: None, author_display_name: "作者".to_string(), summary: String::new(), }; assert!( recommendation_score(&left, &right) > tag_similarity_score(&left.theme_tags, &right.theme_tags) ); } #[test] fn puzzle_leaderboard_entries_sort_by_elapsed_time() { let mut entries = vec![ PuzzleLeaderboardEntry { rank: 0, nickname: "玩家 B".to_string(), elapsed_ms: 5200, is_current_player: false, }, PuzzleLeaderboardEntry { rank: 0, nickname: "玩家 A".to_string(), elapsed_ms: 3100, is_current_player: true, }, ]; entries.sort_by(|left, right| left.elapsed_ms.cmp(&right.elapsed_ms)); for (index, entry) in entries.iter_mut().enumerate() { entry.rank = index as u32 + 1; } assert_eq!(entries[0].nickname, "玩家 A"); assert_eq!(entries[0].rank, 1); assert_eq!(entries[1].nickname, "玩家 B"); assert_eq!(entries[1].rank, 2); } }