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; pub const PUZZLE_FREEZE_TIME_DURATION_MS: u64 = 10_000; pub const PUZZLE_EXTEND_TIME_DURATION_MS: u64 = 60_000; pub const PUZZLE_NEXT_LEVEL_MODE_SAME_WORK: &str = "sameWork"; pub const PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS: &str = "similarWorks"; pub const PUZZLE_NEXT_LEVEL_MODE_NONE: &str = "none"; pub const PUZZLE_SUPPORTED_GRID_SIZES: [u32; 5] = [3, 4, 5, 6, 7]; const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64; // 中文注释:拼图难度只从关卡序号解析,避免切割规格和倒计时在不同入口各写一套。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PuzzleLevelConfig { pub grid_size: u32, pub time_limit_ms: u64, } #[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, Failed, } #[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 PuzzleDraftLevel { pub level_id: String, pub level_name: String, pub picture_description: String, 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 PuzzleResultDraft { #[serde(default)] pub work_title: String, #[serde(default)] pub work_description: String, 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, #[serde(default)] pub levels: Vec, #[serde(default)] pub form_draft: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleFormDraft { pub work_title: Option, pub work_description: Option, pub picture_description: Option, } #[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, #[serde(default)] pub work_title: String, #[serde(default)] pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, #[serde(default)] pub levels: Vec, 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, #[serde(default)] pub recent_play_count_7d: u32, #[serde(default)] pub point_incentive_total_half_points: u64, #[serde(default)] pub point_incentive_claimed_points: u64, 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, #[serde(default)] pub selected_piece_id: Option, #[serde(default)] 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, #[serde(default)] pub level_id: Option, 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, #[serde(default)] pub started_at_ms: u64, #[serde(default)] pub cleared_at_ms: Option, #[serde(default)] pub elapsed_ms: Option, #[serde(default)] pub time_limit_ms: u64, #[serde(default)] pub remaining_ms: u64, #[serde(default)] pub paused_accumulated_ms: u64, #[serde(default)] pub pause_started_at_ms: Option, #[serde(default)] pub freeze_accumulated_ms: u64, #[serde(default)] pub freeze_started_at_ms: Option, #[serde(default)] pub freeze_until_ms: Option, #[serde(default)] pub leaderboard_entries: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, 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, #[serde(default = "default_puzzle_next_level_mode")] pub next_level_mode: String, #[serde(default)] pub next_level_profile_id: Option, #[serde(default)] pub next_level_id: Option, #[serde(default)] pub recommended_next_works: Vec, #[serde(default)] pub leaderboard_entries: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PuzzleRecommendedNextWork { pub profile_id: String, pub level_name: String, pub author_display_name: String, pub theme_tags: Vec, pub cover_image_src: Option, pub similarity_score: f32, } fn default_puzzle_next_level_mode() -> String { PUZZLE_NEXT_LEVEL_MODE_NONE.to_string() } #[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 PuzzleFormDraftSaveInput { pub session_id: String, pub owner_user_id: String, pub seed_text: String, pub saved_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 level_id: Option, pub levels_json: Option, 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 level_id: Option, 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 work_title: Option, pub work_description: Option, pub level_name: Option, pub summary: Option, pub theme_tags: Option>, pub levels_json: 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 work_title: String, pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, pub levels_json: 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 PuzzleWorkLikeRecordInput { pub profile_id: String, pub user_id: String, pub liked_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkPointIncentiveClaimInput { pub profile_id: String, pub owner_user_id: String, pub claimed_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 level_id: Option, 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 PuzzleRunPauseInput { pub run_id: String, pub owner_user_id: String, pub paused: bool, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunPropInput { pub run_id: String, pub owner_user_id: String, pub prop_kind: String, pub used_at_micros: i64, #[serde(default)] pub spent_points: u64, } #[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", Self::Failed => "failed", } } } 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)); let Some(source) = source else { return empty_anchor_pack(); }; if let Some(form_seed) = parse_form_seed_text(&source) { if form_seed.has_any_value() { return build_form_anchor_pack( form_seed.work_title.as_deref().unwrap_or(""), form_seed.picture_description.as_deref().unwrap_or(""), ); } } 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_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack { let normalized_title = normalize_required_string(title); let normalized_description = normalize_required_string(picture_description); let mut pack = empty_anchor_pack(); if let Some(title) = normalized_title.as_ref() { pack.theme_promise.value = title.clone(); pack.theme_promise.status = PuzzleAnchorStatus::Locked; } if let Some(description) = normalized_description.as_ref() { pack.visual_subject.value = description.clone(); pack.visual_subject.status = PuzzleAnchorStatus::Locked; } pack.visual_mood.value = "清晰、适合拼图切块".to_string(); pack.visual_mood.status = PuzzleAnchorStatus::Inferred; pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string(); pack.composition_hooks.status = PuzzleAnchorStatus::Inferred; pack.tags_and_forbidden.value = build_form_tags_and_forbidden( normalized_title.as_deref().unwrap_or(""), normalized_description.as_deref().unwrap_or(""), ); pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred; pack } pub fn build_creator_intent( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], ) -> PuzzleCreatorIntent { PuzzleCreatorIntent { source_mode: if is_form_anchor_pack(anchor_pack) { "form".to_string() } else { "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 { compile_result_draft_from_seed(anchor_pack, messages, None) } pub fn compile_result_draft_from_seed( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], seed_text: Option<&str>, ) -> PuzzleResultDraft { let creator_intent = build_creator_intent(anchor_pack, messages); let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone()); let work_title = build_work_title(anchor_pack); let work_description = resolve_work_description(seed_text, anchor_pack); let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体"); let level_name = build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1); let level = PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: level_name.clone(), picture_description, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), }; PuzzleResultDraft { work_title, work_description: work_description.clone(), level_name, summary: work_description, 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(), levels: vec![level], form_draft: None, } } pub fn build_form_draft_from_seed( anchor_pack: &PuzzleAnchorPack, seed_text: Option<&str>, ) -> PuzzleResultDraft { let form_seed = seed_text.and_then(parse_form_seed_text); build_form_draft_from_parts( anchor_pack, form_seed.as_ref().and_then(|seed| seed.work_title.clone()), form_seed .as_ref() .and_then(|seed| seed.work_description.clone()), form_seed.and_then(|seed| seed.picture_description), ) } pub fn build_form_draft_from_parts( anchor_pack: &PuzzleAnchorPack, work_title: Option, work_description: Option, picture_description: Option, ) -> PuzzleResultDraft { let work_title = work_title.and_then(|value| normalize_required_string(&value)); let work_description = work_description.and_then(|value| normalize_required_string(&value)); let picture_description = picture_description.and_then(|value| normalize_required_string(&value)); let title_for_tags = work_title.as_deref().unwrap_or(""); let picture_for_tags = picture_description.as_deref().unwrap_or(""); let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags)); if tags.is_empty() { tags = vec![ "拼图".to_string(), "插画".to_string(), "清晰构图".to_string(), ]; } let summary = work_description.clone().unwrap_or_default(); let level = PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: String::new(), picture_description: picture_description.clone().unwrap_or_default(), candidates: Vec::new(), selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), }; // 中文注释:这是生成前的表单草稿,只用于创作中心恢复和表单回填,不进入发布就绪判断。 PuzzleResultDraft { work_title: work_title.clone().unwrap_or_default(), work_description: summary.clone(), level_name: String::new(), summary, theme_tags: tags, forbidden_directives: Vec::new(), creator_intent: None, 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(), levels: vec![level], form_draft: Some(PuzzleFormDraft { work_title, work_description, picture_description, }), } } 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 normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft { if draft.work_title.trim().is_empty() { draft.work_title = fallback_text(&draft.anchor_pack.theme_promise.value, &draft.level_name); } if draft.work_description.trim().is_empty() { draft.work_description = draft.summary.clone(); } if draft.levels.is_empty() { draft.levels = vec![PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: draft.level_name.clone(), picture_description: fallback_text( &draft.anchor_pack.visual_subject.value, &draft.summary, ), candidates: draft.candidates.clone(), selected_candidate_id: draft.selected_candidate_id.clone(), cover_image_src: draft.cover_image_src.clone(), cover_asset_id: draft.cover_asset_id.clone(), generation_status: draft.generation_status.clone(), }]; } sync_primary_level_fields(&mut draft); draft } pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) { if let Some(primary_level) = draft.levels.first() { draft.level_name = primary_level.level_name.clone(); draft.candidates = primary_level.candidates.clone(); draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); draft.cover_image_src = primary_level.cover_image_src.clone(); draft.cover_asset_id = primary_level.cover_asset_id.clone(); draft.generation_status = primary_level.generation_status.clone(); } if draft.work_description.trim().is_empty() { draft.work_description = draft.summary.clone(); } draft.summary = draft.work_description.clone(); if draft.form_draft.is_some() { draft.form_draft = Some(PuzzleFormDraft { work_title: normalize_required_string(&draft.work_title), work_description: normalize_required_string(&draft.work_description), picture_description: draft .levels .first() .and_then(|level| normalize_required_string(&level.picture_description)), }); } } pub fn selected_puzzle_level( draft: &PuzzleResultDraft, level_id: Option<&str>, ) -> Option { let normalized = normalize_puzzle_draft(draft.clone()); let requested_level_id = level_id.and_then(normalize_required_string); requested_level_id .as_deref() .and_then(|target_id| { normalized .levels .iter() .find(|level| level.level_id == target_id) .cloned() }) .or_else(|| normalized.levels.first().cloned()) } pub fn replace_puzzle_level( draft: &PuzzleResultDraft, level: PuzzleDraftLevel, ) -> Result { let mut next_draft = normalize_puzzle_draft(draft.clone()); let Some(index) = next_draft .levels .iter() .position(|entry| entry.level_id == level.level_id) else { return Err(PuzzleFieldError::InvalidOperation); }; next_draft.levels[index] = level; sync_primary_level_fields(&mut next_draft); Ok(next_draft) } pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft { let mut next_draft = normalize_puzzle_draft(draft.clone()); let next_index = next_draft.levels.len() + 1; let picture_description = next_draft .levels .first() .map(|level| level.picture_description.clone()) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| fallback_text(&next_draft.anchor_pack.visual_subject.value, "画面主体")); next_draft.levels.push(PuzzleDraftLevel { level_id: format!("puzzle-level-{next_index}"), level_name: build_level_name_from_picture( picture_description.as_str(), &next_draft.theme_tags, next_index, ), picture_description, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), }); sync_primary_level_fields(&mut next_draft); next_draft } pub fn remove_puzzle_level( draft: &PuzzleResultDraft, level_id: &str, ) -> Result { let mut next_draft = normalize_puzzle_draft(draft.clone()); if next_draft.levels.len() <= 1 { return Err(PuzzleFieldError::InvalidOperation); } let normalized_level_id = normalize_required_string(level_id).ok_or(PuzzleFieldError::InvalidOperation)?; next_draft .levels .retain(|level| level.level_id != normalized_level_id); if next_draft.levels.is_empty() { return Err(PuzzleFieldError::InvalidOperation); } sync_primary_level_fields(&mut next_draft); Ok(next_draft) } pub fn build_result_preview( draft: &PuzzleResultDraft, author_display_name: Option<&str>, ) -> PuzzleResultPreviewEnvelope { let normalized_draft = normalize_puzzle_draft(draft.clone()); if normalized_draft.form_draft.is_some() { return PuzzleResultPreviewEnvelope { draft: normalized_draft, blockers: Vec::new(), quality_findings: Vec::new(), publish_ready: false, }; } let blockers = validate_publish_requirements(&normalized_draft, author_display_name); PuzzleResultPreviewEnvelope { draft: normalized_draft, 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 draft = normalize_puzzle_draft(draft.clone()); let mut blockers = Vec::new(); if normalize_required_string(&draft.work_title).is_none() { blockers.push(PuzzleResultPreviewBlocker { id: "missing-work-title".to_string(), code: "MISSING_WORK_TITLE".to_string(), message: "作品名称不能为空".to_string(), }); } if normalize_required_string(&draft.work_description).is_none() { blockers.push(PuzzleResultPreviewBlocker { id: "missing-work-description".to_string(), code: "MISSING_WORK_DESCRIPTION".to_string(), message: "作品描述不能为空".to_string(), }); } for level in &draft.levels { if normalize_required_string(&level.level_name).is_none() { blockers.push(PuzzleResultPreviewBlocker { id: format!("missing-level-name-{}", level.level_id), code: "MISSING_LEVEL_NAME".to_string(), message: "关卡名不能为空".to_string(), }); } if level .cover_image_src .as_deref() .map(str::trim) .unwrap_or("") .is_empty() { blockers.push(PuzzleResultPreviewBlocker { id: format!("missing-cover-image-{}", level.level_id), 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 draft = normalize_puzzle_draft(draft.clone()); 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, work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), 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(), levels: draft.levels.clone(), publication_status: PuzzlePublicationStatus::Draft, updated_at_micros, published_at_micros: None, play_count: 0, remix_count: 0, like_count: 0, recent_play_count_7d: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 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 { let draft = normalize_puzzle_draft(draft.clone()); if !validate_publish_requirements(&draft, Some(&profile.author_display_name)).is_empty() { return Err(PuzzleFieldError::InvalidOperation); } profile.work_title = draft.work_title.clone(); profile.work_description = draft.work_description.clone(); 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.levels = draft.levels.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, work_title: Option, work_description: Option, level_name: Option, summary: Option, theme_tags: Option>, levels: Option>, ) -> Result { let mut next_draft = normalize_puzzle_draft(draft.clone()); if let Some(next_work_title) = work_title && let Some(normalized_work_title) = normalize_required_string(&next_work_title) { next_draft.work_title = normalized_work_title; } if let Some(next_work_description) = work_description && let Some(normalized_work_description) = normalize_required_string(&next_work_description) { next_draft.work_description = normalized_work_description; } if let Some(next_level_name) = level_name && let Some(normalized_level_name) = normalize_required_string(&next_level_name) { if let Some(primary_level) = next_draft.levels.first_mut() { primary_level.level_name = normalized_level_name; } } if let Some(next_summary) = summary && let Some(normalized_summary) = normalize_required_string(&next_summary) { next_draft.work_description = 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; } if let Some(next_levels) = levels { let normalized_levels = normalize_puzzle_levels(next_levels, &next_draft.theme_tags)?; next_draft.levels = normalized_levels; } sync_primary_level_fields(&mut next_draft); Ok(next_draft) } pub fn normalize_puzzle_levels( levels: Vec, theme_tags: &[String], ) -> Result, PuzzleFieldError> { let mut normalized_levels = Vec::new(); for (index, mut level) in levels.into_iter().enumerate() { let level_id = normalize_required_string(&level.level_id) .unwrap_or_else(|| format!("puzzle-level-{}", index + 1)); let picture_description = normalize_required_string(&level.picture_description) .unwrap_or_else(|| format!("第{}关画面", index + 1)); let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| { build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1) }); level.level_id = level_id; level.level_name = level_name; level.picture_description = picture_description; level.generation_status = normalize_required_string(&level.generation_status) .unwrap_or_else(|| "idle".to_string()); normalized_levels.push(level); } if normalized_levels.is_empty() { return Err(PuzzleFieldError::InvalidOperation); } Ok(normalized_levels) } pub fn is_supported_puzzle_grid_size(grid_size: u32) -> bool { PUZZLE_SUPPORTED_GRID_SIZES.contains(&grid_size) } pub fn resolve_puzzle_level_config(level_index: u32) -> PuzzleLevelConfig { let level_index = level_index.max(1); match level_index { 1 => PuzzleLevelConfig { grid_size: 3, time_limit_ms: 300_000, }, 2 => PuzzleLevelConfig { grid_size: 4, time_limit_ms: 300_000, }, 3 => PuzzleLevelConfig { grid_size: 5, time_limit_ms: 300_000, }, 4 => PuzzleLevelConfig { grid_size: 5, time_limit_ms: 210_000, }, _ => { let loop_index = (level_index.saturating_sub(5) % 6) + 5; match loop_index { 5 => PuzzleLevelConfig { grid_size: 5, time_limit_ms: 210_000, }, 6 => PuzzleLevelConfig { grid_size: 6, time_limit_ms: 240_000, }, 7 => PuzzleLevelConfig { grid_size: 5, time_limit_ms: 210_000, }, 8 => PuzzleLevelConfig { grid_size: 7, time_limit_ms: 270_000, }, 9 => PuzzleLevelConfig { grid_size: 5, time_limit_ms: 240_000, }, _ => PuzzleLevelConfig { grid_size: 7, time_limit_ms: 270_000, }, } } } } pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 { resolve_puzzle_level_config(cleared_level_count + 1).grid_size } pub fn resolve_puzzle_level_time_limit_ms_by_index(level_index: u32) -> u64 { resolve_puzzle_level_config(level_index.max(1)).time_limit_ms } pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 { match grid_size { 3 | 4 | 5 => 300_000, 6 => 240_000, 7 => 270_000, _ => 300_000, } } pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { let time_limit_ms = if level.time_limit_ms == 0 { resolve_puzzle_level_time_limit_ms_by_index(level.level_index) } else { level.time_limit_ms }; time_limit_ms.saturating_sub(resolve_effective_elapsed_ms(level, now_ms)) } fn normalize_timer_fields(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { if level.started_at_ms == 0 { level.started_at_ms = now_ms; } if level.time_limit_ms == 0 { level.time_limit_ms = resolve_puzzle_level_time_limit_ms_by_index(level.level_index); } if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing { level.remaining_ms = level.time_limit_ms; } } fn resolve_active_freeze_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { match (level.freeze_started_at_ms, level.freeze_until_ms) { (Some(started_at), Some(until_ms)) => now_ms.min(until_ms).saturating_sub(started_at), _ => 0, } } fn resolve_effective_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { let pause_elapsed_ms = level .pause_started_at_ms .map(|started_at| now_ms.saturating_sub(started_at)) .unwrap_or(0); now_ms .saturating_sub(level.started_at_ms) .saturating_sub(level.paused_accumulated_ms) .saturating_sub(pause_elapsed_ms) .saturating_sub(level.freeze_accumulated_ms) .saturating_sub(resolve_active_freeze_elapsed_ms(level, now_ms)) } fn settle_expired_freeze(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { let (Some(started_at), Some(until_ms)) = (level.freeze_started_at_ms, level.freeze_until_ms) else { return; }; if now_ms < until_ms { return; } level.freeze_accumulated_ms = level .freeze_accumulated_ms .saturating_add(until_ms.saturating_sub(started_at)); level.freeze_started_at_ms = None; level.freeze_until_ms = None; } fn close_level_pause(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { if let Some(pause_started_at_ms) = level.pause_started_at_ms.take() { level.paused_accumulated_ms = level .paused_accumulated_ms .saturating_add(now_ms.saturating_sub(pause_started_at_ms)); } } pub fn resolve_puzzle_run_timer_at(mut run: PuzzleRunSnapshot, now_ms: u64) -> PuzzleRunSnapshot { let Some(current_level) = run.current_level.as_mut() else { return run; }; normalize_timer_fields(current_level, now_ms); if current_level.status != PuzzleRuntimeLevelStatus::Playing { return run; } settle_expired_freeze(current_level, now_ms); let effective_elapsed_ms = resolve_effective_elapsed_ms(current_level, now_ms); current_level.remaining_ms = current_level .time_limit_ms .saturating_sub(effective_elapsed_ms); if current_level.remaining_ms == 0 { current_level.status = PuzzleRuntimeLevelStatus::Failed; current_level.elapsed_ms = Some(current_level.time_limit_ms); current_level.pause_started_at_ms = None; current_level.freeze_started_at_ms = None; current_level.freeze_until_ms = None; } run } pub fn resolve_puzzle_run_timer(run: PuzzleRunSnapshot) -> PuzzleRunSnapshot { resolve_puzzle_run_timer_at(run, current_unix_ms()) } pub fn set_puzzle_run_paused_at( run: &PuzzleRunSnapshot, paused: bool, now_ms: u64, ) -> Result { let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); let current_level = next_run .current_level .as_mut() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status != PuzzleRuntimeLevelStatus::Playing { return Ok(next_run); } if paused { if current_level.pause_started_at_ms.is_none() { current_level.pause_started_at_ms = Some(now_ms); } return Ok(next_run); } close_level_pause(current_level, now_ms); Ok(resolve_puzzle_run_timer_at(next_run, now_ms)) } pub fn set_puzzle_run_paused( run: &PuzzleRunSnapshot, paused: bool, ) -> Result { set_puzzle_run_paused_at(run, paused, current_unix_ms()) } pub fn apply_puzzle_freeze_time_at( run: &PuzzleRunSnapshot, now_ms: u64, ) -> Result { let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); let current_level = next_run .current_level .as_mut() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status != PuzzleRuntimeLevelStatus::Playing { return Err(PuzzleFieldError::InvalidOperation); } close_level_pause(current_level, now_ms); current_level.freeze_started_at_ms = Some(now_ms); current_level.freeze_until_ms = Some(now_ms.saturating_add(PUZZLE_FREEZE_TIME_DURATION_MS)); Ok(next_run) } pub fn apply_puzzle_freeze_time( run: &PuzzleRunSnapshot, ) -> Result { apply_puzzle_freeze_time_at(run, current_unix_ms()) } pub fn extend_failed_puzzle_time_at( run: &PuzzleRunSnapshot, now_ms: u64, ) -> Result { let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); let current_level = next_run .current_level .as_mut() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status != PuzzleRuntimeLevelStatus::Failed { return Err(PuzzleFieldError::InvalidOperation); } let total_consumed_before_extend = current_level .time_limit_ms .saturating_sub(PUZZLE_EXTEND_TIME_DURATION_MS); current_level.status = PuzzleRuntimeLevelStatus::Playing; current_level.elapsed_ms = None; current_level.cleared_at_ms = None; current_level.remaining_ms = PUZZLE_EXTEND_TIME_DURATION_MS; current_level.started_at_ms = now_ms.saturating_sub(total_consumed_before_extend); current_level.paused_accumulated_ms = 0; current_level.pause_started_at_ms = None; current_level.freeze_accumulated_ms = 0; current_level.freeze_started_at_ms = None; current_level.freeze_until_ms = None; Ok(next_run) } pub fn extend_failed_puzzle_time( run: &PuzzleRunSnapshot, ) -> Result { extend_failed_puzzle_time_at(run, current_unix_ms()) } 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 !is_supported_puzzle_grid_size(grid_size) { 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 { start_run_at( run_id, entry_profile, cleared_level_count, current_unix_ms(), ) } pub fn start_run_at( run_id: String, entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, started_at_ms: u64, ) -> 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_at( run_id, entry_profile, cleared_level_count, shuffle_seed, started_at_ms, ) } pub fn start_run_with_shuffle_seed( run_id: String, entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, shuffle_seed: u64, ) -> Result { start_run_with_shuffle_seed_at( run_id, entry_profile, cleared_level_count, shuffle_seed, current_unix_ms(), ) } pub fn start_run_with_shuffle_seed_at( run_id: String, entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, shuffle_seed: u64, started_at_ms: u64, ) -> Result { let level_index = cleared_level_count + 1; let level_config = resolve_puzzle_level_config(level_index); let grid_size = level_config.grid_size; let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), cleared_level_count, current_level_index: level_index, 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, level_id: entry_profile .levels .first() .map(|level| level.level_id.clone()), 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, time_limit_ms: level_config.time_limit_ms, remaining_ms: level_config.time_limit_ms, paused_accumulated_ms: 0, pause_started_at_ms: None, freeze_accumulated_ms: 0, freeze_started_at_ms: None, freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, next_level_mode: default_puzzle_next_level_mode(), next_level_profile_id: None, next_level_id: None, recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }) } pub fn swap_pieces( run: &PuzzleRunSnapshot, first_piece_id: &str, second_piece_id: &str, ) -> Result { swap_pieces_at(run, first_piece_id, second_piece_id, current_unix_ms()) } pub fn swap_pieces_at( run: &PuzzleRunSnapshot, first_piece_id: &str, second_piece_id: &str, now_ms: u64, ) -> 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 timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); let current_level = timed_run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status != PuzzleRuntimeLevelStatus::Playing { 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_at(&timed_run, next_board, now_ms)) } pub fn drag_piece_or_group( run: &PuzzleRunSnapshot, piece_id: &str, target_row: u32, target_col: u32, ) -> Result { drag_piece_or_group_at(run, piece_id, target_row, target_col, current_unix_ms()) } pub fn drag_piece_or_group_at( run: &PuzzleRunSnapshot, piece_id: &str, target_row: u32, target_col: u32, now_ms: u64, ) -> Result { let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); let current_level = timed_run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status != PuzzleRuntimeLevelStatus::Playing { 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_at(&timed_run, next_board, now_ms)) } 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 { advance_next_level_at(run, next_profile, current_unix_ms()) } pub fn advance_next_level_at( run: &PuzzleRunSnapshot, next_profile: &PuzzleWorkProfile, started_at_ms: u64, ) -> 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_level_index = run.current_level_index + 1; let next_level_config = resolve_puzzle_level_config(next_level_index); let next_grid_size = next_level_config.grid_size; let shuffle_seed = puzzle_shuffle_seed( &run.run_id, &next_profile.profile_id, next_level_index, 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: next_level_index, 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: next_level_index, level_id: next_profile .levels .first() .map(|level| level.level_id.clone()), 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, cleared_at_ms: None, elapsed_ms: None, time_limit_ms: next_level_config.time_limit_ms, remaining_ms: next_level_config.time_limit_ms, paused_accumulated_ms: 0, pause_started_at_ms: None, freeze_accumulated_ms: 0, freeze_started_at_ms: None, freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, next_level_mode: default_puzzle_next_level_mode(), next_level_profile_id: None, next_level_id: None, recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }) } pub fn advance_to_new_work_first_level_at( run: &PuzzleRunSnapshot, next_profile: &PuzzleWorkProfile, started_at_ms: u64, ) -> Result { let current_level = run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; if current_level.status != PuzzleRuntimeLevelStatus::Cleared { return Err(PuzzleFieldError::InvalidOperation); } // 中文注释:跨作品代表进入一个新作品,关卡序号、切割规格和倒计时都从第 1 关重新开始。 let next_level_index = 1; let level_config = resolve_puzzle_level_config(next_level_index); let grid_size = level_config.grid_size; let shuffle_seed = puzzle_shuffle_seed( &run.run_id, &next_profile.profile_id, next_level_index, grid_size, ); let next_board = build_initial_board_with_seed(grid_size, shuffle_seed)?; let mut played_profile_ids = run.played_profile_ids.clone(); if !played_profile_ids.contains(&next_profile.profile_id) { played_profile_ids.push(next_profile.profile_id.clone()); } Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), entry_profile_id: next_profile.profile_id.clone(), cleared_level_count: 0, current_level_index: next_level_index, current_grid_size: 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: next_level_index, level_id: next_profile .levels .first() .map(|level| level.level_id.clone()), 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, cleared_at_ms: None, elapsed_ms: None, time_limit_ms: level_config.time_limit_ms, remaining_ms: level_config.time_limit_ms, paused_accumulated_ms: 0, pause_started_at_ms: None, freeze_accumulated_ms: 0, freeze_started_at_ms: None, freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, next_level_mode: default_puzzle_next_level_mode(), next_level_profile_id: None, next_level_id: None, recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }) } pub fn selected_profile_level_after_index( profile: &PuzzleWorkProfile, current_level_index: u32, ) -> Option { if current_level_index == 0 { return None; } let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) .unwrap_or_else(|_| profile.levels.clone()); normalized_levels.get(current_level_index as usize).cloned() } pub fn selected_profile_level_after_runtime_level( profile: &PuzzleWorkProfile, current_level: &PuzzleRuntimeLevelSnapshot, ) -> Option { let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) .unwrap_or_else(|_| profile.levels.clone()); if normalized_levels.len() <= 1 { return None; } let matched_index = current_level .level_id .as_ref() .and_then(|level_id| { normalized_levels .iter() .position(|level| level.level_id == *level_id) }) .or_else(|| { current_level .cover_image_src .as_ref() .and_then(|cover_image_src| { normalized_levels.iter().position(|level| { level.cover_image_src.as_ref() == Some(cover_image_src) && level.level_name == current_level.level_name }) }) }) .or_else(|| { normalized_levels.iter().position(|level| { level.level_name == current_level.level_name && level.cover_image_src == current_level.cover_image_src }) }) .or_else(|| { current_level.level_index.checked_sub(1).and_then(|index| { ((index as usize) < normalized_levels.len()).then_some(index as usize) }) })?; normalized_levels.get(matched_index + 1).cloned() } pub fn selected_profile_level_index(profile: &PuzzleWorkProfile, level_id: &str) -> Option { let target_level_id = normalize_required_string(level_id)?; let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) .unwrap_or_else(|_| profile.levels.clone()); normalized_levels .iter() .position(|level| level.level_id == target_level_id) } pub fn resolve_restart_cleared_level_count(profile: &PuzzleWorkProfile, level_id: &str) -> u32 { // 中文注释:失败重开指定的是当前关 levelId;start_run_at 用“已通关数 + 1”计算当前关,所以这里返回关卡下标。 selected_profile_level_index(profile, level_id).unwrap_or(0) as u32 } pub fn select_next_profile<'a>( current_profile: &PuzzleWorkProfile, played_profile_ids: &[String], candidates: &'a [PuzzleWorkProfile], ) -> Option<&'a PuzzleWorkProfile> { select_next_profiles(current_profile, played_profile_ids, candidates, 1) .into_iter() .next() } pub fn select_next_profiles<'a>( current_profile: &PuzzleWorkProfile, played_profile_ids: &[String], candidates: &'a [PuzzleWorkProfile], limit: usize, ) -> Vec<&'a PuzzleWorkProfile> { if limit == 0 { return Vec::new(); } 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.sort_by(|left, right| { let left_score = recommendation_score(current_profile, left); let right_score = recommendation_score(current_profile, right); right_score .partial_cmp(&left_score) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| { tag_similarity_score(¤t_profile.theme_tags, &right.theme_tags) .partial_cmp(&tag_similarity_score( ¤t_profile.theme_tags, &left.theme_tags, )) .unwrap_or(std::cmp::Ordering::Equal) }) .then_with(|| left.play_count.cmp(&right.play_count)) .then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros)) }); available.truncate(limit); available } 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 { let lexical_score = intersection / union; // 中文注释:优先复用 RPG build 标签的属性亲和度语义模型;拼图自有标签未命中时保留 Jaccard 兜底。 rpg_build_tag_set_similarity(&left_set, &right_set) .map(|semantic_score| semantic_score.max(lexical_score)) .unwrap_or(lexical_score) } } #[derive(Clone, Copy)] struct RpgBuildTagSemanticDefinition { category: &'static str, affinity: [f32; 6], } fn rpg_affinity(strength: f32, agility: f32, intelligence: f32, spirit: f32) -> [f32; 6] { [ strength * 0.72 + spirit * 0.28, agility * 0.88 + intelligence * 0.12, intelligence * 0.78 + agility * 0.22, strength * 0.62 + agility * 0.18 + intelligence * 0.2, spirit * 0.72 + intelligence * 0.28, spirit * 0.74 + strength * 0.26, ] } fn resolve_rpg_build_tag_semantic(tag: &str) -> Option { let normalized = tag.trim().to_lowercase(); let value = normalized.as_str(); let definition = match value { "quickblade" | "快剑" | "快刀" | "决斗者" => { ("style", rpg_affinity(0.35, 1.0, 0.1, 0.05)) } "combo" | "连段" | "连击" | "连锁" => ("style", rpg_affinity(0.3, 0.92, 0.18, 0.08)), "dash" | "突进" | "冲锋" => ("style", rpg_affinity(0.45, 0.95, 0.0, 0.0)), "pursuit" | "追击" => ("style", rpg_affinity(0.38, 0.88, 0.08, 0.02)), "swiftstrike" | "快袭" | "刺袭" | "伏击" => { ("style", rpg_affinity(0.22, 0.98, 0.12, 0.04)) } "ranged" | "远射" | "射击" | "箭矢" => { ("style", rpg_affinity(0.18, 0.82, 0.34, 0.08)) } "guerrilla" | "游击" | "骚扰" => ("style", rpg_affinity(0.24, 0.9, 0.28, 0.12)), "mobility" | "机动" | "敏捷" | "灵活" => { ("style", rpg_affinity(0.18, 1.0, 0.08, 0.08)) } "windrun" | "风行" | "疾行" => ("style", rpg_affinity(0.08, 1.0, 0.1, 0.1)), "heavyhit" | "重击" => ("style", rpg_affinity(1.0, 0.28, 0.02, 0.04)), "burst" | "爆发" => ("style", rpg_affinity(0.72, 0.58, 0.36, 0.08)), "armorbreak" | "破甲" => ("style", rpg_affinity(0.92, 0.28, 0.08, 0.02)), "pressure" | "压制" => ("style", rpg_affinity(0.62, 0.64, 0.1, 0.08)), "bloodrush" | "压血" => ("resource", rpg_affinity(0.84, 0.54, 0.04, 0.18)), "guard" | "守御" | "守卫" | "防御" => { ("defense", rpg_affinity(0.7, 0.18, 0.04, 0.72)) } "barrier" | "护体" | "护罩" | "护盾" => { ("defense", rpg_affinity(0.48, 0.08, 0.2, 0.92)) } "heavyarmor" | "重甲" => ("defense", rpg_affinity(0.88, 0.04, 0.02, 0.54)), "counter" | "反击" | "回击" => ("defense", rpg_affinity(0.66, 0.46, 0.14, 0.36)), "banish" | "镇邪" => ("defense", rpg_affinity(0.24, 0.06, 0.54, 0.88)), "caster" | "法修" | "法师" => ("element", rpg_affinity(0.0, 0.1, 1.0, 0.6)), "mana" | "法力" => ("resource", rpg_affinity(0.02, 0.08, 0.94, 0.74)), "thunder" | "雷法" => ("element", rpg_affinity(0.06, 0.24, 0.96, 0.42)), "formation" | "符阵" | "法阵" => ("element", rpg_affinity(0.08, 0.12, 0.82, 0.96)), "control" | "控场" | "控制" => ("style", rpg_affinity(0.12, 0.34, 0.78, 0.72)), "overload" | "过载" => ("resource", rpg_affinity(0.14, 0.18, 0.92, 0.38)), "heal" | "回复" | "治疗" => ("resource", rpg_affinity(0.02, 0.08, 0.56, 1.0)), "support" | "护持" | "支援" | "祝福" => { ("resource", rpg_affinity(0.14, 0.14, 0.58, 0.98)) } "sustain" | "续战" => ("resource", rpg_affinity(0.34, 0.18, 0.22, 0.9)), "fate" | "命纹" => ("flow", rpg_affinity(0.08, 0.22, 0.72, 0.84)), "fortune" | "机缘" => ("flow", rpg_affinity(0.06, 0.34, 0.7, 0.78)), "cooldown" | "冷却" => ("resource", rpg_affinity(0.04, 0.46, 0.82, 0.4)), "command" | "统御" => ("flow", rpg_affinity(0.38, 0.26, 0.72, 0.82)), "balanced" | "均衡" | "平衡" | "全能" => { ("flow", rpg_affinity(0.58, 0.58, 0.58, 0.58)) } "craft" | "工巧" | "工艺" => ("craft", rpg_affinity(0.24, 0.16, 0.74, 0.5)), "alchemy" | "炼药" | "药剂" => ("craft", rpg_affinity(0.08, 0.16, 0.84, 0.76)), "vanguard" | "先锋" => ("flow", rpg_affinity(0.82, 0.44, 0.08, 0.34)), "berserk" | "狂战" => ("flow", rpg_affinity(0.98, 0.42, 0.0, 0.22)), "spellblade" | "法剑" => ("flow", rpg_affinity(0.42, 0.42, 0.88, 0.38)), "paladin" | "圣佑" | "圣骑士" => ("flow", rpg_affinity(0.58, 0.12, 0.42, 0.96)), "fortress" | "堡垒" => ("flow", rpg_affinity(0.94, 0.04, 0.08, 0.82)), "starter" | "起手" => ("flow", rpg_affinity(0.42, 0.42, 0.42, 0.42)), _ => return None, }; Some(RpgBuildTagSemanticDefinition { category: definition.0, affinity: definition.1, }) } fn normalized_affinity_dot(left: [f32; 6], right: [f32; 6]) -> f32 { let left_magnitude = left.iter().map(|value| value * value).sum::().sqrt(); let right_magnitude = right.iter().map(|value| value * value).sum::().sqrt(); if left_magnitude <= 0.0001 || right_magnitude <= 0.0001 { return 0.0; } left.iter() .zip(right.iter()) .map(|(left_value, right_value)| { (left_value / left_magnitude) * (right_value / right_magnitude) }) .sum::() } fn rpg_build_tag_similarity( left: RpgBuildTagSemanticDefinition, right: RpgBuildTagSemanticDefinition, ) -> f32 { let category_bonus = if left.category == right.category { 0.08 } else { 0.0 }; (normalized_affinity_dot(left.affinity, right.affinity) + category_bonus).min(1.0) } fn rpg_build_tag_directional_similarity( left: &[RpgBuildTagSemanticDefinition], right: &[RpgBuildTagSemanticDefinition], ) -> f32 { if left.is_empty() || right.is_empty() { return 0.0; } let total = left .iter() .map(|left_definition| { right .iter() .map(|right_definition| { rpg_build_tag_similarity(*left_definition, *right_definition) }) .fold(0.0_f32, f32::max) }) .sum::(); total / left.len() as f32 } fn rpg_build_tag_set_similarity( left_tags: &BTreeSet, right_tags: &BTreeSet, ) -> Option { let left_definitions = left_tags .iter() .filter_map(|tag| resolve_rpg_build_tag_semantic(tag)) .collect::>(); let right_definitions = right_tags .iter() .filter_map(|tag| resolve_rpg_build_tag_semantic(tag)) .collect::>(); if left_definitions.is_empty() || right_definitions.is_empty() { return None; } Some( (rpg_build_tag_directional_similarity(&left_definitions, &right_definitions) + rpg_build_tag_directional_similarity(&right_definitions, &left_definitions)) / 2.0, ) } 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() } } #[derive(Clone, Debug, Default, PartialEq, Eq)] struct PuzzleFormSeedParts { work_title: Option, work_description: Option, picture_description: Option, } impl PuzzleFormSeedParts { fn has_any_value(&self) -> bool { self.work_title.is_some() || self.work_description.is_some() || self.picture_description.is_some() } } fn parse_form_seed_text(source: &str) -> Option { let normalized_source = source.trim(); if normalized_source.is_empty() { return None; } let title_marker = if normalized_source.contains("作品名称:") { "作品名称:" } else { "拼图标题:" }; let parts = PuzzleFormSeedParts { work_title: extract_form_seed_value(normalized_source, title_marker), work_description: extract_form_seed_value(normalized_source, "作品描述:"), picture_description: extract_form_seed_value(normalized_source, "画面描述:"), }; parts.has_any_value().then_some(parts) } fn extract_form_seed_value(source: &str, marker: &str) -> Option { let value_start = source.find(marker)? + marker.len(); let value_end = ["作品名称:", "拼图标题:", "作品描述:", "画面描述:"] .into_iter() .filter(|next_marker| *next_marker != marker) .filter_map(|next_marker| { source[value_start..] .find(next_marker) .map(|index| value_start + index) }) .min() .unwrap_or(source.len()); normalize_required_string(&source[value_start..value_end]) } fn build_form_tags_and_forbidden(title: &str, picture_description: &str) -> String { let mut tags = derive_form_theme_tags(title, picture_description); if tags.len() < PUZZLE_MIN_TAG_COUNT { for fallback in ["拼图", "插画", "清晰构图"] { if !tags.iter().any(|tag| tag == fallback) { tags.push(fallback.to_string()); } if tags.len() >= PUZZLE_MIN_TAG_COUNT { break; } } } format!("{};禁止标题字", tags.join("、")) } fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec { let source = format!("{title} {picture_description}"); let keyword_tags = [ ("猫", "猫咪"), ("狗", "小狗"), ("神庙", "神庙遗迹"), ("遗迹", "神庙遗迹"), ("森林", "童话森林"), ("雨", "雨夜"), ("夜", "夜景"), ("城市", "城市奇景"), ("蒸汽", "蒸汽城市"), ("机械", "机械幻想"), ("海", "海岸"), ("花", "花园"), ("雪", "雪景"), ("龙", "幻想生物"), ("灯", "暖灯"), ]; let mut tags = keyword_tags .into_iter() .filter(|(keyword, _)| source.contains(keyword)) .map(|(_, tag)| tag.to_string()) .collect::>(); for value in title .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | '、' | ',' | ';' | ';')) .filter_map(normalize_required_string) { if value.chars().count() <= 8 { tags.push(value); } } normalize_theme_tags(tags) } fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool { matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked) || matches!( anchor_pack.visual_subject.status, PuzzleAnchorStatus::Locked ) } fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String { if is_form_anchor_pack(anchor_pack) { return fallback_text(&anchor_pack.visual_subject.value, "画面主体"); } format!( "{},主体是{},氛围偏{}。", fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"), fallback_text(&anchor_pack.visual_subject.value, "画面主体"), fallback_text(&anchor_pack.visual_mood.value, "温暖") ) } fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String { seed_text .and_then(parse_form_seed_text) .and_then(|parts| { parts .work_description .or(parts.picture_description) .or(parts.work_title) }) .unwrap_or_else(|| build_result_summary(anchor_pack)) } fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String { fallback_text(&anchor_pack.theme_promise.value, "奇景拼图") } 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_from_picture( picture_description: &str, normalized_tags: &[String], level_index: usize, ) -> String { let source = normalize_required_string(picture_description).unwrap_or_default(); for keyword in [ "猫", "狗", "神庙", "遗迹", "森林", "雨夜", "城市", "机械", "海", "花", "雪", "龙", "灯", "塔", ] { if source.contains(keyword) { return format!("{keyword}画面"); } } if let Some(tag) = normalized_tags.first() { return format!("{tag}第{level_index}关"); } format!("第{level_index}关") } 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_deterministic_neighbor_free_pieces(grid_size, shuffle_seed) .or_else(|| 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_deterministic_neighbor_free_pieces( grid_size: u32, shuffle_seed: u64, ) -> Option> { // 中文注释:大棋盘随机命中“无原图相邻贴边”的概率较低,失败后用确定性排列兜底保证稳定开局。 let positions = match grid_size { 3 => build_seeded_3x3_neighbor_free_positions(shuffle_seed), 4 | 6 => build_affine_neighbor_free_positions(grid_size, 1, 1, 2, 1, shuffle_seed), 5 | 7 => { build_affine_neighbor_free_positions(grid_size, 0, 1, 2, grid_size - 1, shuffle_seed) } _ => return None, }; let pieces = build_pieces_from_positions(grid_size, &positions); (!has_any_original_neighbor_pair(&pieces)).then_some(pieces) } fn build_seeded_3x3_neighbor_free_positions(shuffle_seed: u64) -> Vec { const LAYOUTS: [[(u32, u32); 9]; 6] = [ [ (0, 1), (1, 0), (1, 2), (2, 0), (0, 2), (2, 1), (1, 1), (2, 2), (0, 0), ], [ (0, 1), (1, 0), (1, 2), (2, 0), (0, 2), (2, 1), (2, 2), (1, 1), (0, 0), ], [ (0, 1), (1, 0), (1, 2), (2, 0), (2, 2), (0, 0), (1, 1), (0, 2), (2, 1), ], [ (0, 1), (1, 0), (1, 2), (2, 1), (0, 2), (2, 0), (0, 0), (2, 2), (1, 1), ], [ (0, 1), (1, 0), (1, 2), (2, 2), (0, 2), (2, 1), (1, 1), (2, 0), (0, 0), ], [ (0, 1), (1, 0), (2, 1), (2, 0), (2, 2), (0, 2), (1, 2), (0, 0), (1, 1), ], ]; let layout = &LAYOUTS[(shuffle_seed as usize) % LAYOUTS.len()]; layout .into_iter() .map(|(row, col)| PuzzleCellPosition { row: *row, col: *col, }) .collect() } fn build_affine_neighbor_free_positions( grid_size: u32, row_from_row: u32, row_from_col: u32, col_from_row: u32, col_from_col: u32, shuffle_seed: u64, ) -> Vec { let row_offset = (shuffle_seed % u64::from(grid_size)) as u32; let col_offset = ((shuffle_seed / u64::from(grid_size)) % u64::from(grid_size)) as u32; (0..(grid_size * grid_size)) .map(|index| { let row = index / grid_size; let col = index % grid_size; PuzzleCellPosition { row: (row_from_row * row + row_from_col * col + row_offset) % grid_size, col: (col_from_row * row + col_from_col * col + col_offset) % grid_size, } }) .collect() } 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_at( run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot, now_ms: u64, ) -> 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 { current_level.cleared_at_ms = Some(now_ms); current_level.elapsed_ms = Some(resolve_effective_elapsed_ms(current_level, now_ms).max(1_000)); current_level.remaining_ms = 0; } 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) } pub fn current_puzzle_unix_micros() -> i64 { (current_unix_ms() as i64).saturating_mul(1_000) } pub fn puzzle_point_incentive_claimable_points(total_half_points: u64, claimed_points: u64) -> u64 { total_half_points .saturating_div(2) .saturating_sub(claimed_points) } pub fn puzzle_point_incentive_total_after_spend( total_half_points: u64, spent_points: u64, ) -> u64 { total_half_points.saturating_add(spent_points) } #[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(), work_title: format!("{profile_id} 作品"), work_description: "summary".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()), levels: vec![PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: format!("{profile_id} 关"), picture_description: "summary".to_string(), candidates: Vec::new(), selected_candidate_id: None, cover_image_src: Some("/cover.png".to_string()), cover_asset_id: Some("asset-1".to_string()), generation_status: "ready".to_string(), }], publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 100, published_at_micros: Some(100), 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(), } } #[test] fn resolve_grid_size_matches_prd() { assert_eq!(resolve_puzzle_grid_size(0), 3); assert_eq!(resolve_puzzle_grid_size(1), 4); assert_eq!(resolve_puzzle_grid_size(2), 5); assert_eq!(resolve_puzzle_grid_size(3), 5); assert_eq!(resolve_puzzle_grid_size(4), 5); assert_eq!(resolve_puzzle_grid_size(5), 6); assert_eq!(resolve_puzzle_grid_size(6), 5); assert_eq!(resolve_puzzle_grid_size(7), 7); assert_eq!(resolve_puzzle_grid_size(8), 5); assert_eq!(resolve_puzzle_grid_size(9), 7); assert_eq!(resolve_puzzle_grid_size(10), 5); assert_eq!(resolve_puzzle_grid_size(15), 7); } #[test] fn resolve_level_time_limit_matches_prd() { assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(1), 300_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(2), 300_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(3), 300_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(4), 210_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(5), 210_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(6), 240_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(7), 210_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(8), 270_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(9), 240_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(10), 270_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(11), 210_000); assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(16), 270_000); } #[test] fn form_draft_preserves_partial_initial_fields() { let seed_text = "作品名称:月台拼图\n作品描述:"; let anchor_pack = infer_anchor_pack(seed_text, Some(seed_text)); let draft = build_form_draft_from_seed(&anchor_pack, Some(seed_text)); let form_draft = draft.form_draft.expect("form draft should exist"); assert_eq!(form_draft.work_title.as_deref(), Some("月台拼图")); assert_eq!(form_draft.work_description, None); assert_eq!(form_draft.picture_description, None); assert_eq!(draft.work_title, "月台拼图"); assert_eq!(draft.work_description, ""); assert_eq!(draft.level_name, ""); assert_eq!(draft.levels[0].level_name, ""); assert_eq!(draft.anchor_pack.theme_promise.value, "月台拼图"); assert_eq!(draft.anchor_pack.visual_subject.value, ""); } #[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 form_seed_locks_title_and_picture_description_as_primary_anchors() { let anchor_pack = infer_anchor_pack( "作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。", None, ); let draft = compile_result_draft_from_seed( &anchor_pack, &[], Some( "作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。", ), ); assert_eq!(anchor_pack.theme_promise.value, "暖灯猫街"); assert_eq!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked); assert_eq!(anchor_pack.visual_subject.value, "一只猫在雨夜灯牌下回头。"); assert_eq!( anchor_pack.visual_subject.status, PuzzleAnchorStatus::Locked ); assert_eq!(draft.work_title, "暖灯猫街"); assert_eq!(draft.work_description, "一套雨夜猫街主题拼图。"); assert_eq!(draft.summary, "一套雨夜猫街主题拼图。"); assert_eq!(draft.level_name, "猫画面"); assert_eq!( draft.levels[0].picture_description, "一只猫在雨夜灯牌下回头。" ); assert_eq!( draft .creator_intent .as_ref() .map(|intent| intent.source_mode.as_str()), Some("form") ); assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT); } #[test] fn form_seed_keeps_multiline_picture_description() { let anchor_pack = infer_anchor_pack( "拼图标题:雨夜猫街\n画面描述:一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。", None, ); let draft = compile_result_draft(&anchor_pack, &[]); assert_eq!( anchor_pack.visual_subject.value, "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" ); assert_eq!( draft.levels[0].picture_description, "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" ); assert_eq!(draft.summary, draft.work_description); assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪")); assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜")); } #[test] fn tag_similarity_score_uses_jaccard_fallback() { let score = tag_similarity_score( &["蒸汽城市".to_string(), "雨夜".to_string()], &["蒸汽城市".to_string(), "猫咪".to_string()], ); assert!((score - 0.3333).abs() < 0.01); } #[test] fn tag_similarity_score_prefers_rpg_build_semantic_affinity() { let score = tag_similarity_score(&["快剑".to_string()], &["连击".to_string()]); assert!(score > 0.75); } #[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 restart_cleared_count_uses_selected_level_index() { let mut profile = build_published_profile("entry", "owner-a", vec!["机关"]); profile.levels = vec![ PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: "第一关".to_string(), picture_description: "第一关画面".to_string(), candidates: Vec::new(), selected_candidate_id: None, cover_image_src: Some("/level-1.png".to_string()), cover_asset_id: None, generation_status: "ready".to_string(), }, PuzzleDraftLevel { level_id: "puzzle-level-2".to_string(), level_name: "第二关".to_string(), picture_description: "第二关画面".to_string(), candidates: Vec::new(), selected_candidate_id: None, cover_image_src: Some("/level-2.png".to_string()), cover_asset_id: None, generation_status: "ready".to_string(), }, ]; assert_eq!( resolve_restart_cleared_level_count(&profile, "puzzle-level-2"), 1 ); assert_eq!( resolve_restart_cleared_level_count(&profile, "missing-level"), 0 ); } #[test] fn advance_to_new_work_first_level_restarts_level_progress() { let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻", "遗迹"]); let next_profile = build_published_profile("next", "owner-b", vec!["奇幻", "魔法"]); let mut run = start_run("run-cross-work".to_string(), &first_profile, 2).expect("run"); run.cleared_level_count = run.current_level_index; let current_level = run.current_level.as_mut().expect("level"); current_level.status = PuzzleRuntimeLevelStatus::Cleared; current_level.cleared_at_ms = Some(2_000); current_level.elapsed_ms = Some(1_000); let next_run = advance_to_new_work_first_level_at(&run, &next_profile, 3_000).expect("next run"); assert_eq!(next_run.entry_profile_id, "next"); assert_eq!(next_run.cleared_level_count, 0); assert_eq!(next_run.current_level_index, 1); let next_level = next_run.current_level.expect("next level"); assert_eq!(next_level.profile_id, "next"); assert_eq!(next_level.level_index, 1); assert_eq!(next_level.grid_size, 3); assert_eq!(next_level.time_limit_ms, 300_000); } #[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 puzzle_point_incentive_uses_half_points_and_floor_claimable() { // 中文注释:累计单位是 half point,消耗 1 个光点只让作者获得 0.5 个待结算光点。 assert_eq!(puzzle_point_incentive_total_after_spend(0, 1), 1); assert_eq!(puzzle_point_incentive_claimable_points(1, 0), 0); assert_eq!(puzzle_point_incentive_claimable_points(2, 0), 1); assert_eq!(puzzle_point_incentive_claimable_points(5, 1), 1); assert_eq!(puzzle_point_incentive_claimable_points(5, 2), 0); } #[test] fn initial_board_has_no_original_neighbor_pairs() { for grid_size in PUZZLE_SUPPORTED_GRID_SIZES { 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 timer_marks_running_level_failed_after_limit() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let mut run = start_run_with_shuffle_seed("run-timeout".to_string(), &profile, 0, 11).expect("run"); let level = run.current_level.as_mut().expect("level"); level.started_at_ms = current_unix_ms().saturating_sub(level.time_limit_ms + 1_000); let timed_run = resolve_puzzle_run_timer(run); let timed_level = timed_run.current_level.as_ref().expect("level"); assert_eq!(timed_level.status, PuzzleRuntimeLevelStatus::Failed); assert_eq!(timed_level.remaining_ms, 0); assert_eq!(timed_level.elapsed_ms, Some(timed_level.time_limit_ms)); } #[test] fn failed_level_can_extend_one_minute() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let now_ms = current_unix_ms(); let mut run = start_run_with_shuffle_seed("run-extend".to_string(), &profile, 0, 14).expect("run"); let level = run.current_level.as_mut().expect("level"); level.started_at_ms = now_ms.saturating_sub(level.time_limit_ms + 1_000); let failed_run = resolve_puzzle_run_timer_at(run, now_ms); let extended_run = extend_failed_puzzle_time_at(&failed_run, now_ms + 5_000) .expect("extend should succeed"); let extended_level = extended_run.current_level.as_ref().expect("level"); assert_eq!(extended_level.status, PuzzleRuntimeLevelStatus::Playing); assert_eq!(extended_level.remaining_ms, PUZZLE_EXTEND_TIME_DURATION_MS); assert_eq!(extended_level.elapsed_ms, None); assert_eq!(extended_level.cleared_at_ms, None); assert_eq!(extended_level.pause_started_at_ms, None); assert_eq!(extended_level.freeze_until_ms, None); } #[test] fn pause_and_freeze_are_excluded_from_effective_timer() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let mut run = start_run_with_shuffle_seed("run-freeze".to_string(), &profile, 0, 12).expect("run"); let now_ms = current_unix_ms(); let level = run.current_level.as_mut().expect("level"); level.started_at_ms = now_ms.saturating_sub(30_000); level.paused_accumulated_ms = 8_000; level.pause_started_at_ms = Some(now_ms.saturating_sub(5_000)); level.freeze_accumulated_ms = 4_000; level.freeze_started_at_ms = Some(now_ms.saturating_sub(3_000)); level.freeze_until_ms = Some(now_ms.saturating_add(7_000)); let remaining_ms = resolve_puzzle_runtime_remaining_ms(level, now_ms); assert_eq!(remaining_ms, level.time_limit_ms.saturating_sub(10_000)); } #[test] fn reference_preview_can_keep_run_paused_until_overlay_closes() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); let run = start_run_with_shuffle_seed("run-reference".to_string(), &profile, 0, 13).expect("run"); let paused_run = set_puzzle_run_paused(&run, true).expect("pause"); let still_paused_run = set_puzzle_run_paused(&paused_run, true).expect("reference pause"); assert!( still_paused_run .current_level .as_ref() .and_then(|level| level.pause_started_at_ms) .is_some() ); } #[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("雨夜猫塔".to_string()), Some("一张更聚焦猫咪塔楼的夜景拼图。".to_string()), Some(vec![ "雨夜".to_string(), "猫咪".to_string(), "遗迹".to_string(), ]), None, ) .expect("publish overrides should succeed"); assert_eq!(updated.level_name, "雨夜猫塔"); assert_eq!(updated.work_title, "雨夜猫塔作品"); 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, None, None, Some(vec!["蒸汽".to_string()]), None, ) .expect_err("invalid tag count should fail"); assert_eq!(error, PuzzleFieldError::InvalidTagCount); } }