use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, error::Error, fmt, }; use serde::{Deserialize, Serialize}; use shared_kernel::{normalize_required_string, normalize_string_list}; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; pub const PUZZLE_AGENT_SESSION_ID_PREFIX: &str = "puzzle-session-"; pub const PUZZLE_AGENT_MESSAGE_ID_PREFIX: &str = "puzzle-message-"; pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-"; pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-"; pub const PUZZLE_MIN_TAG_COUNT: usize = 3; pub const PUZZLE_MAX_TAG_COUNT: usize = 6; const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzleAgentStage { CollectingAnchors, DraftReady, ImageRefining, ReadyToPublish, Published, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzleAnchorStatus { Missing, Inferred, Confirmed, Locked, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzleAgentMessageRole { User, Assistant, System, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzleAgentMessageKind { Chat, Summary, ActionResult, Warning, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzlePublicationStatus { Draft, Published, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzleRuntimeLevelStatus { Playing, Cleared, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAnchorItem { pub key: String, pub label: String, pub value: String, pub status: PuzzleAnchorStatus, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAnchorPack { pub theme_promise: PuzzleAnchorItem, pub visual_subject: PuzzleAnchorItem, pub visual_mood: PuzzleAnchorItem, pub composition_hooks: PuzzleAnchorItem, pub tags_and_forbidden: PuzzleAnchorItem, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleCreatorIntent { pub source_mode: String, pub raw_messages_summary: String, pub theme_promise: String, pub visual_subject: String, pub visual_mood: Vec, pub composition_hooks: Vec, pub theme_tags: Vec, pub forbidden_directives: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleGeneratedImageCandidate { pub candidate_id: String, pub image_src: String, pub asset_id: String, pub prompt: String, pub actual_prompt: Option, pub source_type: String, pub selected: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleResultDraft { pub level_name: String, pub summary: String, pub theme_tags: Vec, pub forbidden_directives: Vec, pub creator_intent: Option, pub anchor_pack: PuzzleAnchorPack, pub candidates: Vec, pub selected_candidate_id: Option, pub cover_image_src: Option, pub cover_asset_id: Option, pub generation_status: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleResultPreviewBlocker { pub id: String, pub code: String, pub message: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleResultPreviewFinding { pub id: String, pub severity: String, pub code: String, pub message: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleResultPreviewEnvelope { pub draft: PuzzleResultDraft, pub blockers: Vec, pub quality_findings: Vec, pub publish_ready: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentMessageSnapshot { pub message_id: String, pub session_id: String, pub role: PuzzleAgentMessageRole, pub kind: PuzzleAgentMessageKind, pub text: String, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSuggestedAction { pub id: String, pub action_type: String, pub label: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionSnapshot { pub session_id: String, pub owner_user_id: String, pub seed_text: String, pub current_turn: u32, pub progress_percent: u32, pub stage: PuzzleAgentStage, pub anchor_pack: PuzzleAnchorPack, pub draft: Option, pub messages: Vec, pub last_assistant_reply: Option, pub published_profile_id: Option, pub suggested_actions: Vec, pub result_preview: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkProfile { pub work_id: String, pub profile_id: String, pub owner_user_id: String, pub source_session_id: Option, pub author_display_name: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, pub publication_status: PuzzlePublicationStatus, pub updated_at_micros: i64, pub published_at_micros: Option, #[serde(default)] pub play_count: u32, #[serde(default)] pub remix_count: u32, #[serde(default)] pub like_count: u32, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPack, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleCellPosition { pub row: u32, pub col: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzlePieceState { pub piece_id: String, pub correct_row: u32, pub correct_col: u32, pub current_row: u32, pub current_col: u32, pub merged_group_id: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleMergedGroupState { pub group_id: String, pub piece_ids: Vec, pub occupied_cells: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleLeaderboardEntry { pub rank: u32, pub nickname: String, pub elapsed_ms: u64, pub is_current_player: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleBoardSnapshot { pub rows: u32, pub cols: u32, pub pieces: Vec, pub merged_groups: Vec, pub selected_piece_id: Option, pub all_tiles_resolved: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRuntimeLevelSnapshot { pub run_id: String, pub level_index: u32, pub grid_size: u32, pub profile_id: String, pub level_name: String, pub author_display_name: String, pub theme_tags: Vec, pub cover_image_src: Option, pub board: PuzzleBoardSnapshot, pub status: PuzzleRuntimeLevelStatus, pub started_at_ms: u64, pub cleared_at_ms: Option, pub elapsed_ms: Option, pub leaderboard_entries: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunSnapshot { pub run_id: String, pub entry_profile_id: String, pub cleared_level_count: u32, pub current_level_index: u32, pub current_grid_size: u32, pub played_profile_ids: Vec, pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, pub leaderboard_entries: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionCreateInput { pub session_id: String, pub owner_user_id: String, pub seed_text: String, pub welcome_message_id: String, pub welcome_message_text: String, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionGetInput { pub session_id: String, pub owner_user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentMessageSubmitInput { pub session_id: String, pub owner_user_id: String, pub user_message_id: String, pub user_message_text: String, pub submitted_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentMessageFinalizeInput { pub session_id: String, pub owner_user_id: String, pub assistant_message_id: Option, pub assistant_reply_text: Option, pub stage: PuzzleAgentStage, pub progress_percent: u32, pub anchor_pack_json: String, pub error_message: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleDraftCompileInput { pub session_id: String, pub owner_user_id: String, pub compiled_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleGeneratedImagesSaveInput { pub session_id: String, pub owner_user_id: String, pub candidates_json: String, pub saved_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleSelectCoverImageInput { pub session_id: String, pub owner_user_id: String, pub candidate_id: String, pub selected_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzlePublishInput { pub session_id: String, pub owner_user_id: String, pub work_id: String, pub profile_id: String, pub author_display_name: String, pub level_name: Option, pub summary: Option, pub theme_tags: Option>, pub published_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorksListInput { pub owner_user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkGetInput { pub profile_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkDeleteInput { pub profile_id: String, pub owner_user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkUpsertInput { pub profile_id: String, pub owner_user_id: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkRemixInput { pub source_profile_id: String, pub target_owner_user_id: String, pub target_session_id: String, pub target_profile_id: String, pub target_work_id: String, pub author_display_name: String, pub welcome_message_id: String, pub remixed_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, pub started_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunGetInput { pub run_id: String, pub owner_user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunSwapInput { pub run_id: String, pub owner_user_id: String, pub first_piece_id: String, pub second_piece_id: String, pub swapped_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunDragInput { pub run_id: String, pub owner_user_id: String, pub piece_id: String, pub target_row: u32, pub target_col: u32, pub dragged_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunNextLevelInput { pub run_id: String, pub owner_user_id: String, pub advanced_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleLeaderboardSubmitInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, pub grid_size: u32, pub elapsed_ms: u64, pub nickname: String, pub submitted_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionProcedureResult { pub ok: bool, pub session_json: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorksProcedureResult { pub ok: bool, pub items_json: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkProcedureResult { pub ok: bool, pub item_json: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunProcedureResult { pub ok: bool, pub run_json: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum PuzzleFieldError { MissingText, MissingSessionId, MissingProfileId, MissingRunId, MissingPieceId, MissingAuthorDisplayName, InvalidTagCount, InvalidGridSize, InvalidTargetCell, InvalidOperation, } impl fmt::Display for PuzzleFieldError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingText => write!(f, "必填文本缺失"), Self::MissingSessionId => write!(f, "session_id 缺失"), Self::MissingProfileId => write!(f, "profile_id 缺失"), Self::MissingRunId => write!(f, "run_id 缺失"), Self::MissingPieceId => write!(f, "piece_id 缺失"), Self::MissingAuthorDisplayName => write!(f, "author_display_name 缺失"), Self::InvalidTagCount => write!(f, "标签数量不合法"), Self::InvalidGridSize => write!(f, "网格规格不合法"), Self::InvalidTargetCell => write!(f, "目标格子不合法"), Self::InvalidOperation => write!(f, "操作不合法"), } } } impl Error for PuzzleFieldError {} impl PuzzleAgentStage { pub fn as_str(self) -> &'static str { match self { Self::CollectingAnchors => "collecting_anchors", Self::DraftReady => "draft_ready", Self::ImageRefining => "image_refining", Self::ReadyToPublish => "ready_to_publish", Self::Published => "published", } } } impl PuzzleAnchorStatus { pub fn as_str(self) -> &'static str { match self { Self::Missing => "missing", Self::Inferred => "inferred", Self::Confirmed => "confirmed", Self::Locked => "locked", } } } impl PuzzleAgentMessageRole { pub fn as_str(self) -> &'static str { match self { Self::User => "user", Self::Assistant => "assistant", Self::System => "system", } } } impl PuzzleAgentMessageKind { pub fn as_str(self) -> &'static str { match self { Self::Chat => "chat", Self::Summary => "summary", Self::ActionResult => "action_result", Self::Warning => "warning", } } } impl PuzzlePublicationStatus { pub fn as_str(self) -> &'static str { match self { Self::Draft => "draft", Self::Published => "published", } } } impl PuzzleRuntimeLevelStatus { pub fn as_str(self) -> &'static str { match self { Self::Playing => "playing", Self::Cleared => "cleared", } } } pub fn empty_anchor_pack() -> PuzzleAnchorPack { PuzzleAnchorPack { theme_promise: PuzzleAnchorItem { key: "themePromise".to_string(), label: "题材承诺".to_string(), value: String::new(), status: PuzzleAnchorStatus::Missing, }, visual_subject: PuzzleAnchorItem { key: "visualSubject".to_string(), label: "画面主体".to_string(), value: String::new(), status: PuzzleAnchorStatus::Missing, }, visual_mood: PuzzleAnchorItem { key: "visualMood".to_string(), label: "视觉气质".to_string(), value: String::new(), status: PuzzleAnchorStatus::Missing, }, composition_hooks: PuzzleAnchorItem { key: "compositionHooks".to_string(), label: "拼图记忆点".to_string(), value: String::new(), status: PuzzleAnchorStatus::Missing, }, tags_and_forbidden: PuzzleAnchorItem { key: "tagsAndForbidden".to_string(), label: "标签与禁忌".to_string(), value: String::new(), status: PuzzleAnchorStatus::Missing, }, } } pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> PuzzleAnchorPack { let source = normalize_required_string(latest_message.unwrap_or(seed_text)) .or_else(|| normalize_required_string(seed_text)) .unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string()); let mut pack = empty_anchor_pack(); pack.theme_promise.value = infer_theme_promise(&source); pack.theme_promise.status = PuzzleAnchorStatus::Inferred; pack.visual_subject.value = infer_visual_subject(&source); pack.visual_subject.status = PuzzleAnchorStatus::Inferred; pack.visual_mood.value = infer_visual_mood(&source); pack.visual_mood.status = PuzzleAnchorStatus::Inferred; pack.composition_hooks.value = infer_composition_hooks(&source); pack.composition_hooks.status = PuzzleAnchorStatus::Inferred; pack.tags_and_forbidden.value = infer_tags_and_forbidden(&source); pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred; pack } pub fn build_creator_intent( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], ) -> PuzzleCreatorIntent { PuzzleCreatorIntent { source_mode: "agent_chat".to_string(), raw_messages_summary: messages .iter() .rev() .take(4) .map(|entry| entry.text.clone()) .collect::>() .join(" / "), theme_promise: anchor_pack.theme_promise.value.clone(), visual_subject: anchor_pack.visual_subject.value.clone(), visual_mood: split_phrase_list(&anchor_pack.visual_mood.value), composition_hooks: split_phrase_list(&anchor_pack.composition_hooks.value), theme_tags: split_phrase_list(&anchor_pack.tags_and_forbidden.value) .into_iter() .take(PUZZLE_MAX_TAG_COUNT) .collect(), forbidden_directives: vec![extract_forbidden_directive( &anchor_pack.tags_and_forbidden.value, )], } } pub fn compile_result_draft( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], ) -> PuzzleResultDraft { let creator_intent = build_creator_intent(anchor_pack, messages); let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone()); let level_name = build_level_name(anchor_pack, &normalized_tags); PuzzleResultDraft { level_name, summary: format!( "{},主体是{},氛围偏{}。", fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"), fallback_text(&anchor_pack.visual_subject.value, "画面主体"), fallback_text(&anchor_pack.visual_mood.value, "温暖") ), theme_tags: normalized_tags, forbidden_directives: creator_intent.forbidden_directives.clone(), creator_intent: Some(creator_intent), anchor_pack: anchor_pack.clone(), candidates: Vec::new(), selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), } } pub fn build_generated_candidates( session_id: &str, prompt_text: Option<&str>, draft: &PuzzleResultDraft, candidate_count: u32, now_micros: i64, ) -> Result, PuzzleFieldError> { let session_id = normalize_required_string(session_id).ok_or(PuzzleFieldError::MissingSessionId)?; let count = candidate_count.max(1).min(1); let prompt = normalize_required_string(prompt_text.unwrap_or(&draft.summary)) .unwrap_or_else(|| draft.summary.clone()); Ok((0..count) .map(|index| { let candidate_seed = now_micros + i64::from(index); let candidate_id = format!("{session_id}-candidate-{}", index + 1); PuzzleGeneratedImageCandidate { candidate_id: candidate_id.clone(), // 拼图图片的正式持久化由 api-server 上传 OSS;这里仅保留 reducer // 单测/保底路径构造,前缀必须与 OSS 兼容路由一致,不能再指向 public 目录。 image_src: format!( "/generated-puzzle-assets/{session_id}/{candidate_seed}/cover.svg" ), asset_id: format!("puzzle-cover-{candidate_seed}"), prompt: prompt.clone(), actual_prompt: Some(prompt.clone()), source_type: "generated".to_string(), selected: index == 0, } }) .collect()) } pub fn apply_selected_candidate( mut draft: PuzzleResultDraft, candidate_id: &str, ) -> Result { let candidate_id = normalize_required_string(candidate_id).ok_or(PuzzleFieldError::MissingText)?; let mut selected_cover_image_src = None; let mut selected_cover_asset_id = None; let mut matched = false; for candidate in &mut draft.candidates { candidate.selected = candidate.candidate_id == candidate_id; if candidate.selected { matched = true; selected_cover_image_src = Some(candidate.image_src.clone()); selected_cover_asset_id = Some(candidate.asset_id.clone()); } } if !matched { return Err(PuzzleFieldError::InvalidOperation); } draft.selected_candidate_id = Some(candidate_id); draft.cover_image_src = selected_cover_image_src; draft.cover_asset_id = selected_cover_asset_id; draft.generation_status = "ready".to_string(); Ok(draft) } pub fn build_result_preview( draft: &PuzzleResultDraft, author_display_name: Option<&str>, ) -> PuzzleResultPreviewEnvelope { let blockers = validate_publish_requirements(draft, author_display_name); PuzzleResultPreviewEnvelope { draft: draft.clone(), blockers, quality_findings: Vec::new(), publish_ready: validate_publish_requirements(draft, author_display_name).is_empty(), } } pub fn validate_publish_requirements( draft: &PuzzleResultDraft, author_display_name: Option<&str>, ) -> Vec { let mut blockers = Vec::new(); if normalize_required_string(&draft.level_name).is_none() { blockers.push(PuzzleResultPreviewBlocker { id: "missing-level-name".to_string(), code: "MISSING_LEVEL_NAME".to_string(), message: "关卡名不能为空".to_string(), }); } if draft .cover_image_src .as_deref() .map(str::trim) .unwrap_or("") .is_empty() { blockers.push(PuzzleResultPreviewBlocker { id: "missing-cover-image".to_string(), code: "MISSING_COVER_IMAGE".to_string(), message: "正式拼图图片尚未确定".to_string(), }); } if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT || draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT { blockers.push(PuzzleResultPreviewBlocker { id: "invalid-tag-count".to_string(), code: "INVALID_TAG_COUNT".to_string(), message: "正式标签数量必须在 3 到 6 之间".to_string(), }); } if normalize_required_string(author_display_name.unwrap_or("")).is_none() { blockers.push(PuzzleResultPreviewBlocker { id: "missing-author".to_string(), code: "MISSING_AUTHOR".to_string(), message: "作者信息不可读".to_string(), }); } blockers } pub fn create_work_profile( work_id: String, profile_id: String, owner_user_id: String, source_session_id: Option, author_display_name: String, draft: &PuzzleResultDraft, updated_at_micros: i64, ) -> Result { let author_display_name = normalize_required_string(author_display_name) .ok_or(PuzzleFieldError::MissingAuthorDisplayName)?; let preview = build_result_preview(draft, Some(&author_display_name)); Ok(PuzzleWorkProfile { work_id, profile_id, owner_user_id, source_session_id, author_display_name, level_name: draft.level_name.clone(), summary: draft.summary.clone(), theme_tags: normalize_theme_tags(draft.theme_tags.clone()), cover_image_src: draft.cover_image_src.clone(), cover_asset_id: draft.cover_asset_id.clone(), publication_status: PuzzlePublicationStatus::Draft, updated_at_micros, published_at_micros: None, play_count: 0, remix_count: 0, like_count: 0, publish_ready: preview.publish_ready, anchor_pack: draft.anchor_pack.clone(), }) } pub fn publish_work_profile( mut profile: PuzzleWorkProfile, draft: &PuzzleResultDraft, published_at_micros: i64, ) -> Result { if !validate_publish_requirements(draft, Some(&profile.author_display_name)).is_empty() { return Err(PuzzleFieldError::InvalidOperation); } profile.level_name = draft.level_name.clone(); profile.summary = draft.summary.clone(); profile.theme_tags = normalize_theme_tags(draft.theme_tags.clone()); profile.cover_image_src = draft.cover_image_src.clone(); profile.cover_asset_id = draft.cover_asset_id.clone(); profile.publication_status = PuzzlePublicationStatus::Published; profile.publish_ready = true; profile.updated_at_micros = published_at_micros; profile.published_at_micros = Some(published_at_micros); Ok(profile) } /// 在发布前把结果页的轻量编辑字段覆盖回草稿真相。 /// 这里只允许覆盖 PRD 明确要求的关卡名、摘要与标签,不额外扩到更多结果页元数据。 pub fn apply_publish_overrides_to_draft( draft: &PuzzleResultDraft, level_name: Option, summary: Option, theme_tags: Option>, ) -> Result { let mut next_draft = draft.clone(); if let Some(next_level_name) = level_name && let Some(normalized_level_name) = normalize_required_string(&next_level_name) { next_draft.level_name = normalized_level_name; } if let Some(next_summary) = summary && let Some(normalized_summary) = normalize_required_string(&next_summary) { next_draft.summary = normalized_summary; } if let Some(next_theme_tags) = theme_tags { let normalized_theme_tags = normalize_theme_tags(next_theme_tags); if normalized_theme_tags.len() < PUZZLE_MIN_TAG_COUNT || normalized_theme_tags.len() > PUZZLE_MAX_TAG_COUNT { return Err(PuzzleFieldError::InvalidTagCount); } next_draft.theme_tags = normalized_theme_tags; } Ok(next_draft) } pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 { if cleared_level_count >= 3 { 4 } else { 3 } } pub fn build_initial_board(grid_size: u32) -> Result { build_initial_board_with_seed(grid_size, 0) } pub fn build_initial_board_with_seed( grid_size: u32, shuffle_seed: u64, ) -> Result { if !matches!(grid_size, 3 | 4) { return Err(PuzzleFieldError::InvalidGridSize); } let pieces = build_initial_pieces_without_correct_neighbors(grid_size, shuffle_seed); Ok(rebuild_board_snapshot(grid_size, pieces, None)) } pub fn start_run( run_id: String, entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, ) -> Result { let grid_size = resolve_puzzle_grid_size(cleared_level_count); let shuffle_seed = puzzle_shuffle_seed( &run_id, &entry_profile.profile_id, cleared_level_count + 1, grid_size, ); start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed) } pub fn start_run_with_shuffle_seed( run_id: String, entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, shuffle_seed: u64, ) -> Result { let grid_size = resolve_puzzle_grid_size(cleared_level_count); let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; let started_at_ms = current_unix_ms(); Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), cleared_level_count, current_level_index: cleared_level_count + 1, current_grid_size: grid_size, played_profile_ids: vec![entry_profile.profile_id.clone()], previous_level_tags: entry_profile.theme_tags.clone(), current_level: Some(PuzzleRuntimeLevelSnapshot { run_id, level_index: cleared_level_count + 1, grid_size, profile_id: entry_profile.profile_id.clone(), level_name: entry_profile.level_name.clone(), author_display_name: entry_profile.author_display_name.clone(), theme_tags: entry_profile.theme_tags.clone(), cover_image_src: entry_profile.cover_image_src.clone(), board, status: PuzzleRuntimeLevelStatus::Playing, started_at_ms, cleared_at_ms: None, elapsed_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, leaderboard_entries: Vec::new(), }) } pub fn swap_pieces( run: &PuzzleRunSnapshot, first_piece_id: &str, second_piece_id: &str, ) -> Result { let first_piece_id = normalize_required_string(first_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; let second_piece_id = normalize_required_string(second_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; let current_level = run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status == PuzzleRuntimeLevelStatus::Cleared { return Err(PuzzleFieldError::InvalidOperation); } let mut pieces = current_level.board.pieces.clone(); let first_index = pieces .iter() .position(|piece| piece.piece_id == first_piece_id) .ok_or(PuzzleFieldError::MissingPieceId)?; let second_index = pieces .iter() .position(|piece| piece.piece_id == second_piece_id) .ok_or(PuzzleFieldError::MissingPieceId)?; let (first_row, first_col) = ( pieces[first_index].current_row, pieces[first_index].current_col, ); let (second_row, second_col) = ( pieces[second_index].current_row, pieces[second_index].current_col, ); pieces[first_index].current_row = second_row; pieces[first_index].current_col = second_col; pieces[second_index].current_row = first_row; pieces[second_index].current_col = first_col; let affected_cells = [ PuzzleCellPosition { row: first_row, col: first_col, }, PuzzleCellPosition { row: second_row, col: second_col, }, ]; let next_board = rebuild_board_snapshot_for_affected_cells( current_level.grid_size, ¤t_level.board, pieces, affected_cells, None, ); Ok(with_next_board(run, next_board)) } pub fn drag_piece_or_group( run: &PuzzleRunSnapshot, piece_id: &str, target_row: u32, target_col: u32, ) -> Result { let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; let current_level = run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status == PuzzleRuntimeLevelStatus::Cleared { return Err(PuzzleFieldError::InvalidOperation); } let grid_size = current_level.grid_size; if target_row >= grid_size || target_col >= grid_size { return Err(PuzzleFieldError::InvalidTargetCell); } let mut pieces = current_level.board.pieces.clone(); let piece_index = pieces .iter() .position(|piece| piece.piece_id == piece_id) .ok_or(PuzzleFieldError::MissingPieceId)?; let source_group_id = pieces[piece_index].merged_group_id.clone(); let operation_cells = match source_group_id { Some(group_id) => drag_group(&mut pieces, &group_id, target_row, target_col, grid_size)?, None => drag_single_piece(&mut pieces, piece_index, target_row, target_col)?, }; let next_board = rebuild_board_snapshot_for_affected_cells( grid_size, ¤t_level.board, pieces, operation_cells, None, ); Ok(with_next_board(run, next_board)) } pub fn rebuild_board_snapshot_for_affected_cells( grid_size: u32, previous_board: &PuzzleBoardSnapshot, pieces: Vec, affected_cells: impl IntoIterator, selected_piece_id: Option, ) -> PuzzleBoardSnapshot { let affected_scope = expand_affected_cells(grid_size, affected_cells); if affected_scope.is_empty() || previous_board.merged_groups.is_empty() { return rebuild_board_snapshot(grid_size, pieces, selected_piece_id); } let mut recalculated_piece_ids = pieces .iter() .filter(|piece| affected_scope.contains(&(piece.current_row, piece.current_col))) .map(|piece| piece.piece_id.clone()) .collect::>(); let previous_piece_by_id = previous_board .pieces .iter() .map(|piece| (piece.piece_id.clone(), piece)) .collect::>(); for piece_id in recalculated_piece_ids.clone() { if let Some(previous_piece) = previous_piece_by_id.get(&piece_id) && let Some(group_id) = previous_piece.merged_group_id.as_deref() { add_previous_group_piece_ids(previous_board, group_id, &mut recalculated_piece_ids); } } let mut preserved_groups = Vec::new(); for group in &previous_board.merged_groups { if group .piece_ids .iter() .any(|piece_id| recalculated_piece_ids.contains(piece_id)) { continue; } let occupied_cells = group .piece_ids .iter() .filter_map(|piece_id| { pieces .iter() .find(|piece| piece.piece_id == *piece_id) .map(|piece| PuzzleCellPosition { row: piece.current_row, col: piece.current_col, }) }) .collect::>(); if occupied_cells.len() == group.piece_ids.len() { preserved_groups.push(PuzzleMergedGroupState { group_id: group.group_id.clone(), piece_ids: group.piece_ids.clone(), occupied_cells, }); } } let recalculated_pieces = pieces .iter() .filter(|piece| recalculated_piece_ids.contains(&piece.piece_id)) .cloned() .collect::>(); let mut next_groups = preserved_groups; next_groups.extend(resolve_merged_groups(&recalculated_pieces)); rebuild_board_snapshot_with_groups(grid_size, pieces, next_groups, selected_piece_id) } pub fn advance_next_level( run: &PuzzleRunSnapshot, next_profile: &PuzzleWorkProfile, ) -> Result { let current_level = run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status != PuzzleRuntimeLevelStatus::Cleared { return Err(PuzzleFieldError::InvalidOperation); } let next_cleared_count = run.cleared_level_count; let next_grid_size = resolve_puzzle_grid_size(next_cleared_count); let shuffle_seed = puzzle_shuffle_seed( &run.run_id, &next_profile.profile_id, run.current_level_index + 1, next_grid_size, ); let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?; let mut played_profile_ids = run.played_profile_ids.clone(); played_profile_ids.push(next_profile.profile_id.clone()); Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), entry_profile_id: run.entry_profile_id.clone(), cleared_level_count: next_cleared_count, current_level_index: run.current_level_index + 1, current_grid_size: next_grid_size, played_profile_ids, previous_level_tags: next_profile.theme_tags.clone(), current_level: Some(PuzzleRuntimeLevelSnapshot { run_id: run.run_id.clone(), level_index: run.current_level_index + 1, grid_size: next_grid_size, profile_id: next_profile.profile_id.clone(), level_name: next_profile.level_name.clone(), author_display_name: next_profile.author_display_name.clone(), theme_tags: next_profile.theme_tags.clone(), cover_image_src: next_profile.cover_image_src.clone(), board: next_board, status: PuzzleRuntimeLevelStatus::Playing, started_at_ms: current_unix_ms(), cleared_at_ms: None, elapsed_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, leaderboard_entries: Vec::new(), }) } pub fn select_next_profile<'a>( current_profile: &PuzzleWorkProfile, played_profile_ids: &[String], candidates: &'a [PuzzleWorkProfile], ) -> Option<&'a PuzzleWorkProfile> { let mut available = candidates .iter() .filter(|candidate| { candidate.publication_status == PuzzlePublicationStatus::Published && candidate.cover_image_src.is_some() && !candidate.theme_tags.is_empty() && candidate.profile_id != current_profile.profile_id }) .collect::>(); let has_unplayed = available .iter() .any(|candidate| !played_profile_ids.contains(&candidate.profile_id)); if has_unplayed { available.retain(|candidate| !played_profile_ids.contains(&candidate.profile_id)); } else if let Some(last_played) = played_profile_ids.last() { available.retain(|candidate| candidate.profile_id != *last_played); } available.into_iter().max_by(|left, right| { let left_score = recommendation_score(current_profile, left); let right_score = recommendation_score(current_profile, right); left_score .partial_cmp(&right_score) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| { tag_similarity_score(¤t_profile.theme_tags, &left.theme_tags) .partial_cmp(&tag_similarity_score( ¤t_profile.theme_tags, &right.theme_tags, )) .unwrap_or(std::cmp::Ordering::Equal) }) .then_with(|| right.play_count.cmp(&left.play_count)) .then_with(|| left.updated_at_micros.cmp(&right.updated_at_micros)) }) } pub fn recommendation_score( current_profile: &PuzzleWorkProfile, candidate: &PuzzleWorkProfile, ) -> f32 { let tag_similarity = tag_similarity_score(¤t_profile.theme_tags, &candidate.theme_tags); let same_author_score = if current_profile.owner_user_id == candidate.owner_user_id { 1.0 } else { 0.0 }; tag_similarity * 0.7 + same_author_score * 0.3 } pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32 { let left_set = normalize_theme_tags(left_tags.to_vec()) .into_iter() .collect::>(); let right_set = normalize_theme_tags(right_tags.to_vec()) .into_iter() .collect::>(); if left_set.is_empty() && right_set.is_empty() { return 0.0; } let intersection = left_set.intersection(&right_set).count() as f32; let union = left_set.union(&right_set).count() as f32; if union <= f32::EPSILON { 0.0 } else { intersection / union } } pub fn normalize_theme_tags(tags: Vec) -> Vec { let alias_map = BTreeMap::from([ ("蒸汽", "蒸汽城市"), ("蒸汽朋克", "蒸汽城市"), ("遗迹", "神庙遗迹"), ("森林", "童话森林"), ("夜雨", "雨夜"), ("发光猫", "猫咪"), ]); let mut normalized = normalize_string_list(tags) .into_iter() .flat_map(|value| split_phrase_list(&value)) .map(|value| { alias_map .get(value.as_str()) .map(|alias| (*alias).to_string()) .unwrap_or(value) }) .collect::>(); normalized.sort(); normalized.dedup(); normalized.into_iter().take(PUZZLE_MAX_TAG_COUNT).collect() } fn infer_theme_promise(source: &str) -> String { if source.contains("神庙") { "探索遗迹中的奇幻想象".to_string() } else if source.contains("雨") { "在雨夜中寻找视觉线索".to_string() } else if source.contains("猫") { "一眼记住可爱的猫咪奇景".to_string() } else { "用一张强识别度画面承诺幻想题材".to_string() } } fn infer_visual_subject(source: &str) -> String { if source.contains("猫") { "发光猫咪".to_string() } else if source.contains("神庙") { "巨大遗迹入口".to_string() } else if source.contains("城市") { "蒸汽城市核心地标".to_string() } else { "画面中央的核心主体".to_string() } } fn infer_visual_mood(source: &str) -> String { if source.contains("悬疑") { "悬疑、静谧".to_string() } else if source.contains("温暖") { "温暖、柔和".to_string() } else if source.contains("机械") { "机械、奇诡".to_string() } else { "梦幻、清晰".to_string() } } fn infer_composition_hooks(source: &str) -> String { if source.contains("塔") { "高塔轮廓、纵向构图、亮色焦点".to_string() } else if source.contains("遗迹") { "入口轮廓、对称台阶、地标雕像".to_string() } else { "主体轮廓、色块分区、地标元素".to_string() } } fn infer_tags_and_forbidden(source: &str) -> String { if source.contains("神庙") { "神庙遗迹、童话森林、雨夜;禁止标题字".to_string() } else if source.contains("猫") { "猫咪、童话森林、发光;禁止水印".to_string() } else { "蒸汽城市、雨夜、奇幻;禁止按钮".to_string() } } fn extract_forbidden_directive(source: &str) -> String { if let Some((_, tail)) = source.split_once(';') { return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string()); } "禁止标题字".to_string() } fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String { if let Some(tag) = normalized_tags.first() { return format!("{tag}拼图"); } if let Some(subject) = normalize_required_string(&anchor_pack.visual_subject.value) { return subject.chars().take(8).collect::(); } "奇景拼图".to_string() } fn fallback_text(value: &str, fallback: &str) -> String { normalize_required_string(value).unwrap_or_else(|| fallback.to_string()) } fn split_phrase_list(value: &str) -> Vec { value .replace(',', ",") .replace('、', ",") .replace(';', ",") .split(',') .filter_map(normalize_required_string) .collect() } fn puzzle_shuffle_seed(run_id: &str, profile_id: &str, level_index: u32, grid_size: u32) -> u64 { let mut hash = 0xcbf2_9ce4_8422_2325_u64; for byte in run_id .bytes() .chain(profile_id.bytes()) .chain(level_index.to_le_bytes()) .chain(grid_size.to_le_bytes()) { hash ^= u64::from(byte); hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } hash } fn shuffle_positions(positions: &mut [PuzzleCellPosition], seed: u64) { if positions.len() <= 1 { return; } let mut state = seed ^ ((positions.len() as u64) << 32) ^ 0x9e37_79b9_7f4a_7c15; for index in (1..positions.len()).rev() { state = state .wrapping_mul(6_364_136_223_846_793_005) .wrapping_add(1_442_695_040_888_963_407); let swap_index = (state % ((index + 1) as u64)) as usize; positions.swap(index, swap_index); } } fn build_initial_pieces_without_correct_neighbors( grid_size: u32, shuffle_seed: u64, ) -> Vec { let base_positions = build_correct_positions(grid_size); for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS { let mut positions = base_positions.clone(); shuffle_positions( &mut positions, shuffle_seed.wrapping_add(attempt.wrapping_mul(0x9e37_79b9_7f4a_7c15)), ); ensure_board_is_not_solved(&mut positions, grid_size); let pieces = build_pieces_from_positions(grid_size, &positions); if !has_any_original_neighbor_pair(&pieces) { return pieces; } } // 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。 let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed) .unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions)); debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces)); fallback_pieces } fn build_correct_positions(grid_size: u32) -> Vec { let total = grid_size * grid_size; (0..total) .map(|index| PuzzleCellPosition { row: index / grid_size, col: index % grid_size, }) .collect() } fn build_pieces_from_positions( grid_size: u32, positions: &[PuzzleCellPosition], ) -> Vec { positions .iter() .enumerate() .map(|(index, current)| { let index = index as u32; PuzzlePieceState { piece_id: format!("piece-{index}"), correct_row: index / grid_size, correct_col: index % grid_size, current_row: current.row, current_col: current.col, merged_group_id: None, } }) .collect() } fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u32) { if positions.len() <= 1 { return; } let is_solved = positions.iter().enumerate().all(|(index, position)| { position.row == index as u32 / grid_size && position.col == index as u32 % grid_size }); if is_solved { positions.rotate_left(1); } } fn has_any_original_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool { let pieces_by_cell = pieces .iter() .map(|piece| ((piece.current_row, piece.current_col), piece)) .collect::>(); pieces.iter().any(|piece| { neighbor_cells(piece.current_row, piece.current_col) .into_iter() .filter_map(|cell| pieces_by_cell.get(&cell)) .any(|neighbor| are_original_neighbors(piece, neighbor)) }) } fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool { left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1 } fn build_original_neighbor_free_pieces( grid_size: u32, shuffle_seed: u64, ) -> Option> { let total = (grid_size * grid_size) as usize; let mut piece_order = (0..total as u32).collect::>(); sort_indices_by_seed(&mut piece_order, shuffle_seed ^ 0xa076_1d64_78bd_642f); let mut cell_order = build_correct_positions(grid_size); sort_cells_by_seed(&mut cell_order, shuffle_seed ^ 0xe703_7ed1_a0b4_28db); let mut placements = vec![None; total]; let mut used_cells = BTreeSet::new(); if place_neighbor_free_piece( grid_size, &piece_order, &cell_order, 0, &mut placements, &mut used_cells, ) { Some( placements .into_iter() .enumerate() .filter_map(|(index, current)| { current.map(|current| PuzzlePieceState { piece_id: format!("piece-{index}"), correct_row: index as u32 / grid_size, correct_col: index as u32 % grid_size, current_row: current.row, current_col: current.col, merged_group_id: None, }) }) .collect(), ) } else { None } } fn place_neighbor_free_piece( grid_size: u32, piece_order: &[u32], cell_order: &[PuzzleCellPosition], depth: usize, placements: &mut [Option], used_cells: &mut BTreeSet<(u32, u32)>, ) -> bool { let Some(piece_index) = piece_order.get(depth).copied() else { return true; }; for cell in cell_order { if used_cells.contains(&(cell.row, cell.col)) { continue; } if cell.row == piece_index / grid_size && cell.col == piece_index % grid_size { continue; } if violates_original_neighbor_free_rule(grid_size, piece_index, cell.clone(), placements) { continue; } placements[piece_index as usize] = Some(cell.clone()); used_cells.insert((cell.row, cell.col)); if place_neighbor_free_piece( grid_size, piece_order, cell_order, depth + 1, placements, used_cells, ) { return true; } used_cells.remove(&(cell.row, cell.col)); placements[piece_index as usize] = None; } false } fn violates_original_neighbor_free_rule( grid_size: u32, piece_index: u32, cell: PuzzleCellPosition, placements: &[Option], ) -> bool { placements .iter() .enumerate() .filter_map(|(placed_index, placed_cell)| { placed_cell .as_ref() .map(|placed_cell| (placed_index as u32, placed_cell)) }) .any(|(placed_index, placed_cell)| { let original_neighbors = (piece_index / grid_size).abs_diff(placed_index / grid_size) + (piece_index % grid_size).abs_diff(placed_index % grid_size) == 1; let current_neighbors = cell.row.abs_diff(placed_cell.row) + cell.col.abs_diff(placed_cell.col) == 1; original_neighbors && current_neighbors }) } fn sort_indices_by_seed(indices: &mut [u32], seed: u64) { indices.sort_by_key(|index| seeded_order_key(seed, u64::from(*index))); } fn sort_cells_by_seed(cells: &mut [PuzzleCellPosition], seed: u64) { cells.sort_by_key(|cell| seeded_order_key(seed, u64::from(cell.row * 16 + cell.col))); } fn seeded_order_key(seed: u64, value: u64) -> u64 { let mut state = seed ^ value.wrapping_mul(0x9e37_79b9_7f4a_7c15); state ^= state >> 30; state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9); state ^= state >> 27; state = state.wrapping_mul(0x94d0_49bb_1331_11eb); state ^ (state >> 31) } fn rebuild_board_snapshot( grid_size: u32, pieces: Vec, selected_piece_id: Option, ) -> PuzzleBoardSnapshot { let merged_groups = resolve_merged_groups(&pieces); rebuild_board_snapshot_with_groups(grid_size, pieces, merged_groups, selected_piece_id) } fn rebuild_board_snapshot_with_groups( grid_size: u32, mut pieces: Vec, merged_groups: Vec, selected_piece_id: Option, ) -> PuzzleBoardSnapshot { let merged_groups = normalize_group_ids(merged_groups); let group_by_piece = merged_groups .iter() .flat_map(|group| { group .piece_ids .iter() .cloned() .map(|piece_id| (piece_id, group.group_id.clone())) }) .collect::>(); for piece in &mut pieces { piece.merged_group_id = group_by_piece.get(&piece.piece_id).cloned(); } let all_pieces_in_correct_cells = pieces.iter().all(|piece| { piece.correct_row == piece.current_row && piece.correct_col == piece.current_col }); let all_pieces_merged_into_one_group = merged_groups .iter() .any(|group| group.piece_ids.len() == pieces.len() && pieces.len() > 1); let all_tiles_resolved = all_pieces_in_correct_cells || all_pieces_merged_into_one_group; PuzzleBoardSnapshot { rows: grid_size, cols: grid_size, pieces, merged_groups, selected_piece_id, all_tiles_resolved, } } fn normalize_group_ids(groups: Vec) -> Vec { groups .into_iter() .enumerate() .map(|(index, group)| PuzzleMergedGroupState { group_id: format!("group-{}", index + 1), ..group }) .collect() } fn expand_affected_cells( grid_size: u32, cells: impl IntoIterator, ) -> BTreeSet<(u32, u32)> { let mut scope = BTreeSet::new(); for cell in cells { if cell.row >= grid_size || cell.col >= grid_size { continue; } scope.insert((cell.row, cell.col)); for (row, col) in neighbor_cells(cell.row, cell.col) { if row < grid_size && col < grid_size { scope.insert((row, col)); } } } scope } fn add_previous_group_piece_ids( previous_board: &PuzzleBoardSnapshot, group_id: &str, piece_ids: &mut BTreeSet, ) { if let Some(group) = previous_board .merged_groups .iter() .find(|group| group.group_id == group_id) { piece_ids.extend(group.piece_ids.iter().cloned()); } } fn resolve_merged_groups(pieces: &[PuzzlePieceState]) -> Vec { let pieces_by_cell = pieces .iter() .map(|piece| ((piece.current_row, piece.current_col), piece)) .collect::>(); let pieces_by_id = pieces .iter() .map(|piece| (piece.piece_id.clone(), piece)) .collect::>(); let mut visited = BTreeSet::new(); let mut groups = Vec::new(); for piece in pieces { if visited.contains(&piece.piece_id) { continue; } let mut queue = VecDeque::from([piece.piece_id.clone()]); let mut collected_ids = Vec::new(); while let Some(current_piece_id) = queue.pop_front() { if !visited.insert(current_piece_id.clone()) { continue; } let current_piece = match pieces_by_id.get(¤t_piece_id) { Some(value) => *value, None => continue, }; collected_ids.push(current_piece_id.clone()); for (neighbor_row, neighbor_col) in neighbor_cells(current_piece.current_row, current_piece.current_col) { if let Some(neighbor_piece) = pieces_by_cell.get(&(neighbor_row, neighbor_col)) && are_correct_neighbors(current_piece, neighbor_piece) { queue.push_back(neighbor_piece.piece_id.clone()); } } } if collected_ids.len() <= 1 { continue; } let occupied_cells = collected_ids .iter() .filter_map(|piece_id| pieces_by_id.get(piece_id).copied()) .map(|piece| PuzzleCellPosition { row: piece.current_row, col: piece.current_col, }) .collect::>(); groups.push(PuzzleMergedGroupState { group_id: format!("group-{}", groups.len() + 1), piece_ids: collected_ids, occupied_cells, }); } groups } fn neighbor_cells(row: u32, col: u32) -> Vec<(u32, u32)> { let mut neighbors = Vec::new(); if row > 0 { neighbors.push((row - 1, col)); } neighbors.push((row + 1, col)); if col > 0 { neighbors.push((row, col - 1)); } neighbors.push((row, col + 1)); neighbors } fn are_correct_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool { let current_row_delta = right.current_row as i32 - left.current_row as i32; let current_col_delta = right.current_col as i32 - left.current_col as i32; let correct_row_delta = right.correct_row as i32 - left.correct_row as i32; let correct_col_delta = right.correct_col as i32 - left.correct_col as i32; (current_row_delta.abs() + current_col_delta.abs()) == 1 && current_row_delta == correct_row_delta && current_col_delta == correct_col_delta } fn drag_single_piece( pieces: &mut [PuzzlePieceState], piece_index: usize, target_row: u32, target_col: u32, ) -> Result, PuzzleFieldError> { let target_index = pieces .iter() .position(|piece| piece.current_row == target_row && piece.current_col == target_col) .ok_or(PuzzleFieldError::InvalidTargetCell)?; let mut affected_cells = vec![ PuzzleCellPosition { row: pieces[piece_index].current_row, col: pieces[piece_index].current_col, }, PuzzleCellPosition { row: target_row, col: target_col, }, ]; if let Some(target_group_id) = pieces[target_index].merged_group_id.clone() { for piece in pieces .iter_mut() .filter(|piece| piece.merged_group_id.as_deref() == Some(target_group_id.as_str())) { affected_cells.push(PuzzleCellPosition { row: piece.current_row, col: piece.current_col, }); piece.merged_group_id = None; } } let (source_row, source_col) = ( pieces[piece_index].current_row, pieces[piece_index].current_col, ); pieces[piece_index].current_row = target_row; pieces[piece_index].current_col = target_col; if target_index != piece_index { pieces[target_index].current_row = source_row; pieces[target_index].current_col = source_col; } Ok(affected_cells) } fn drag_group( pieces: &mut [PuzzlePieceState], group_id: &str, target_row: u32, target_col: u32, grid_size: u32, ) -> Result, PuzzleFieldError> { let group_indices = pieces .iter() .enumerate() .filter_map(|(index, piece)| { (piece.merged_group_id.as_deref() == Some(group_id)).then_some(index) }) .collect::>(); if group_indices.is_empty() { return Err(PuzzleFieldError::InvalidOperation); } let anchor_piece = &pieces[group_indices[0]]; let row_offset = target_row as i32 - anchor_piece.current_row as i32; let col_offset = target_col as i32 - anchor_piece.current_col as i32; let mut target_positions = Vec::new(); for &index in &group_indices { let next_row = pieces[index].current_row as i32 + row_offset; let next_col = pieces[index].current_col as i32 + col_offset; if next_row < 0 || next_col < 0 || next_row >= grid_size as i32 || next_col >= grid_size as i32 { return Err(PuzzleFieldError::InvalidTargetCell); } target_positions.push((index, next_row as u32, next_col as u32)); } let moving_piece_ids = group_indices .iter() .map(|index| pieces[*index].piece_id.clone()) .collect::>(); let source_positions = group_indices .iter() .map(|index| (pieces[*index].current_row, pieces[*index].current_col)) .collect::>(); let mut affected_cells = source_positions .iter() .map(|(row, col)| PuzzleCellPosition { row: *row, col: *col, }) .collect::>(); for (index, next_row, next_col) in &target_positions { affected_cells.push(PuzzleCellPosition { row: *next_row, col: *next_col, }); if let Some(target_piece_index) = pieces.iter().position(|piece| { piece.current_row == *next_row && piece.current_col == *next_col && !moving_piece_ids.contains(&piece.piece_id) }) { let fallback = source_positions .iter() .find(|position| { !target_positions .iter() .any(|(_, row, col)| row == &position.0 && col == &position.1) }) .copied() .ok_or(PuzzleFieldError::InvalidOperation)?; pieces[target_piece_index].merged_group_id = None; affected_cells.push(PuzzleCellPosition { row: pieces[target_piece_index].current_row, col: pieces[target_piece_index].current_col, }); affected_cells.push(PuzzleCellPosition { row: fallback.0, col: fallback.1, }); pieces[target_piece_index].current_row = fallback.0; pieces[target_piece_index].current_col = fallback.1; } pieces[*index].current_row = *next_row; pieces[*index].current_col = *next_col; } Ok(affected_cells) } fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot { let mut next_run = run.clone(); let is_cleared = next_board.all_tiles_resolved; let next_level_status = if is_cleared { PuzzleRuntimeLevelStatus::Cleared } else { PuzzleRuntimeLevelStatus::Playing }; if let Some(current_level) = next_run.current_level.as_mut() { current_level.board = next_board; if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { let cleared_at_ms = current_unix_ms(); current_level.cleared_at_ms = Some(cleared_at_ms); current_level.elapsed_ms = Some( cleared_at_ms .saturating_sub(current_level.started_at_ms) .max(1_000), ); } current_level.status = next_level_status; } if is_cleared && run.current_level.as_ref().map(|level| level.status) != Some(PuzzleRuntimeLevelStatus::Cleared) { next_run.cleared_level_count += 1; } next_run } fn current_unix_ms() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_millis() as u64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; fn build_published_profile( profile_id: &str, owner_user_id: &str, tags: Vec<&str>, ) -> PuzzleWorkProfile { PuzzleWorkProfile { work_id: format!("work-{profile_id}"), profile_id: profile_id.to_string(), owner_user_id: owner_user_id.to_string(), source_session_id: None, author_display_name: "作者".to_string(), level_name: format!("{profile_id} 关"), summary: "summary".to_string(), theme_tags: tags.into_iter().map(|value| value.to_string()).collect(), cover_image_src: Some("/cover.png".to_string()), cover_asset_id: Some("asset-1".to_string()), publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 100, published_at_micros: Some(100), play_count: 0, remix_count: 0, like_count: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), } } #[test] fn resolve_grid_size_matches_prd() { assert_eq!(resolve_puzzle_grid_size(0), 3); assert_eq!(resolve_puzzle_grid_size(2), 3); assert_eq!(resolve_puzzle_grid_size(3), 4); } #[test] fn normalize_theme_tags_dedups_aliases() { assert_eq!( normalize_theme_tags(vec![ "蒸汽".to_string(), "蒸汽朋克".to_string(), "雨夜".to_string(), "雨夜".to_string() ]), vec!["蒸汽城市".to_string(), "雨夜".to_string()] ); } #[test] fn generated_candidate_uses_oss_compatible_prefix_and_single_image() { 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) .expect("candidates should build"); assert_eq!(candidates.len(), 1); assert!( candidates[0] .image_src .starts_with("/generated-puzzle-assets/session-1/") ); let legacy_public_prefix = ["generated-puzzle", "covers"].join("-"); assert!(!candidates[0].image_src.contains(&legacy_public_prefix)); } #[test] fn tag_similarity_score_uses_jaccard() { let score = tag_similarity_score( &["蒸汽城市".to_string(), "雨夜".to_string()], &["蒸汽城市".to_string(), "猫咪".to_string()], ); assert!((score - 0.3333).abs() < 0.01); } #[test] fn select_next_profile_prefers_same_tags_and_author() { let current = build_published_profile("a", "owner-a", vec!["蒸汽城市", "雨夜"]); let candidates = vec![ build_published_profile("b", "owner-a", vec!["蒸汽城市", "雨夜"]), build_published_profile("c", "owner-c", vec!["猫咪", "森林"]), ]; let selected = select_next_profile(¤t, &["a".to_string()], &candidates).expect("should select"); assert_eq!(selected.profile_id, "b"); } #[test] fn swap_pieces_marks_cleared_when_back_to_origin() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let run = start_run("run-1".to_string(), &profile, 0).expect("run"); let current_level = run.current_level.clone().expect("level"); let first_piece = current_level.board.pieces[0].clone(); let second_piece = current_level.board.pieces[1].clone(); let swapped = swap_pieces(&run, &first_piece.piece_id, &second_piece.piece_id).expect("swap"); assert_eq!( swapped .current_level .as_ref() .expect("level") .board .pieces .len(), 9 ); } #[test] fn initial_board_shuffle_changes_by_run_id() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let first = start_run("run-random-a".to_string(), &profile, 0).expect("first run"); let second = start_run("run-random-b".to_string(), &profile, 0).expect("second run"); let first_positions = first .current_level .expect("first level") .board .pieces .into_iter() .map(|piece| (piece.current_row, piece.current_col)) .collect::>(); let second_positions = second .current_level .expect("second level") .board .pieces .into_iter() .map(|piece| (piece.current_row, piece.current_col)) .collect::>(); assert_ne!(first_positions, second_positions); } #[test] fn initial_board_has_no_original_neighbor_pairs() { for grid_size in [3, 4] { for shuffle_seed in 0..128 { let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board"); assert!(board.merged_groups.is_empty()); assert!( !has_any_original_neighbor_pair(&board.pieces), "grid_size={grid_size}, shuffle_seed={shuffle_seed}" ); } } } #[test] fn correct_neighbors_auto_merge_after_swap() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let mut run = start_run_with_shuffle_seed("run-merge".to_string(), &profile, 0, 7).expect("run"); let current_level = run.current_level.as_mut().expect("level"); current_level.board = rebuild_board_snapshot( 3, vec![ PuzzlePieceState { piece_id: "piece-0".to_string(), correct_row: 0, correct_col: 0, current_row: 1, current_col: 1, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-1".to_string(), correct_row: 0, correct_col: 1, current_row: 0, current_col: 1, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-2".to_string(), correct_row: 0, correct_col: 2, current_row: 2, current_col: 2, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-3".to_string(), correct_row: 1, correct_col: 0, current_row: 0, current_col: 2, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-4".to_string(), correct_row: 1, correct_col: 1, current_row: 1, current_col: 0, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-5".to_string(), correct_row: 1, correct_col: 2, current_row: 2, current_col: 0, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-6".to_string(), correct_row: 2, correct_col: 0, current_row: 0, current_col: 0, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-7".to_string(), correct_row: 2, correct_col: 1, current_row: 1, current_col: 2, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-8".to_string(), correct_row: 2, correct_col: 2, current_row: 2, current_col: 1, merged_group_id: None, }, ], None, ); let swapped = swap_pieces(&run, "piece-0", "piece-6").expect("swap"); let board = &swapped.current_level.as_ref().expect("level").board; let group = board .merged_groups .iter() .find(|group| { group.piece_ids.contains(&"piece-0".to_string()) && group.piece_ids.contains(&"piece-1".to_string()) }) .expect("piece-0 and piece-1 should merge"); assert_eq!(group.piece_ids.len(), 2); } #[test] fn single_piece_dragging_into_group_splits_target_group() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let mut run = start_run_with_shuffle_seed("run-split".to_string(), &profile, 0, 9).expect("run"); let current_level = run.current_level.as_mut().expect("level"); current_level.board = rebuild_board_snapshot( 3, vec![ PuzzlePieceState { piece_id: "piece-0".to_string(), correct_row: 0, correct_col: 0, current_row: 0, current_col: 0, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-1".to_string(), correct_row: 0, correct_col: 1, current_row: 0, current_col: 1, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-2".to_string(), correct_row: 0, correct_col: 2, current_row: 2, current_col: 2, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-3".to_string(), correct_row: 1, correct_col: 0, current_row: 1, current_col: 0, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-4".to_string(), correct_row: 1, correct_col: 1, current_row: 1, current_col: 1, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-5".to_string(), correct_row: 1, correct_col: 2, current_row: 1, current_col: 2, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-6".to_string(), correct_row: 2, correct_col: 0, current_row: 2, current_col: 0, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-7".to_string(), correct_row: 2, correct_col: 1, current_row: 2, current_col: 1, merged_group_id: None, }, PuzzlePieceState { piece_id: "piece-8".to_string(), correct_row: 2, correct_col: 2, current_row: 0, current_col: 2, merged_group_id: None, }, ], None, ); let dragged = drag_piece_or_group(&run, "piece-8", 0, 1).expect("drag"); let board = &dragged.current_level.as_ref().expect("level").board; assert_eq!( board .pieces .iter() .find(|piece| piece.piece_id == "piece-8") .map(|piece| (piece.current_row, piece.current_col)), Some((0, 1)) ); assert!( board .merged_groups .iter() .all(|group| !(group.piece_ids.contains(&"piece-0".to_string()) && group.piece_ids.contains(&"piece-1".to_string()))) ); } #[test] fn one_full_board_group_marks_level_cleared() { let pieces = (0..9) .map(|index| PuzzlePieceState { piece_id: format!("piece-{index}"), correct_row: index / 3, correct_col: index % 3, current_row: index / 3, current_col: (index + 1) % 3, merged_group_id: None, }) .collect::>(); let board = rebuild_board_snapshot_with_groups( 3, pieces, vec![PuzzleMergedGroupState { group_id: "group-full".to_string(), piece_ids: (0..9).map(|index| format!("piece-{index}")).collect(), occupied_cells: (0..9) .map(|index| PuzzleCellPosition { row: index / 3, col: (index + 1) % 3, }) .collect(), }], None, ); assert!(board.all_tiles_resolved); } #[test] fn apply_publish_overrides_updates_draft_truth() { let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); let draft = compile_result_draft(&anchor_pack, &[]); let updated = apply_publish_overrides_to_draft( &draft, Some("雨夜猫塔".to_string()), Some("一张更聚焦猫咪塔楼的夜景拼图。".to_string()), Some(vec![ "雨夜".to_string(), "猫咪".to_string(), "遗迹".to_string(), ]), ) .expect("publish overrides should succeed"); assert_eq!(updated.level_name, "雨夜猫塔"); assert_eq!(updated.summary, "一张更聚焦猫咪塔楼的夜景拼图。"); assert_eq!( updated.theme_tags, vec![ "猫咪".to_string(), "神庙遗迹".to_string(), "雨夜".to_string() ] ); } #[test] fn apply_publish_overrides_rejects_invalid_tag_count() { let anchor_pack = infer_anchor_pack("蒸汽城市", Some("蒸汽城市")); let draft = compile_result_draft(&anchor_pack, &[]); let error = apply_publish_overrides_to_draft(&draft, None, None, Some(vec!["蒸汽".to_string()])) .expect_err("invalid tag count should fail"); assert_eq!(error, PuzzleFieldError::InvalidTagCount); } }