4181 lines
145 KiB
Rust
4181 lines
145 KiB
Rust
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<String>,
|
||
pub composition_hooks: Vec<String>,
|
||
pub theme_tags: Vec<String>,
|
||
pub forbidden_directives: Vec<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
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<PuzzleGeneratedImageCandidate>,
|
||
pub selected_candidate_id: Option<String>,
|
||
pub cover_image_src: Option<String>,
|
||
pub cover_asset_id: Option<String>,
|
||
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<String>,
|
||
pub forbidden_directives: Vec<String>,
|
||
pub creator_intent: Option<PuzzleCreatorIntent>,
|
||
pub anchor_pack: PuzzleAnchorPack,
|
||
pub candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||
pub selected_candidate_id: Option<String>,
|
||
pub cover_image_src: Option<String>,
|
||
pub cover_asset_id: Option<String>,
|
||
pub generation_status: String,
|
||
#[serde(default)]
|
||
pub levels: Vec<PuzzleDraftLevel>,
|
||
#[serde(default)]
|
||
pub form_draft: Option<PuzzleFormDraft>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleFormDraft {
|
||
pub work_title: Option<String>,
|
||
pub work_description: Option<String>,
|
||
pub picture_description: Option<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleResultPreviewBlocker {
|
||
pub id: String,
|
||
pub code: String,
|
||
pub message: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleResultPreviewFinding {
|
||
pub id: String,
|
||
pub severity: String,
|
||
pub code: String,
|
||
pub message: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleResultPreviewEnvelope {
|
||
pub draft: PuzzleResultDraft,
|
||
pub blockers: Vec<PuzzleResultPreviewBlocker>,
|
||
pub quality_findings: Vec<PuzzleResultPreviewFinding>,
|
||
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<PuzzleResultDraft>,
|
||
pub messages: Vec<PuzzleAgentMessageSnapshot>,
|
||
pub last_assistant_reply: Option<String>,
|
||
pub published_profile_id: Option<String>,
|
||
pub suggested_actions: Vec<PuzzleAgentSuggestedAction>,
|
||
pub result_preview: Option<PuzzleResultPreviewEnvelope>,
|
||
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<String>,
|
||
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<String>,
|
||
pub cover_image_src: Option<String>,
|
||
pub cover_asset_id: Option<String>,
|
||
#[serde(default)]
|
||
pub levels: Vec<PuzzleDraftLevel>,
|
||
pub publication_status: PuzzlePublicationStatus,
|
||
pub updated_at_micros: i64,
|
||
pub published_at_micros: Option<i64>,
|
||
#[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<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub occupied_cells: Vec<PuzzleCellPosition>,
|
||
}
|
||
|
||
#[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<PuzzlePieceState>,
|
||
pub merged_groups: Vec<PuzzleMergedGroupState>,
|
||
#[serde(default)]
|
||
pub selected_piece_id: Option<String>,
|
||
#[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<String>,
|
||
pub grid_size: u32,
|
||
pub profile_id: String,
|
||
pub level_name: String,
|
||
pub author_display_name: String,
|
||
pub theme_tags: Vec<String>,
|
||
pub cover_image_src: Option<String>,
|
||
pub board: PuzzleBoardSnapshot,
|
||
pub status: PuzzleRuntimeLevelStatus,
|
||
#[serde(default)]
|
||
pub started_at_ms: u64,
|
||
#[serde(default)]
|
||
pub cleared_at_ms: Option<u64>,
|
||
#[serde(default)]
|
||
pub elapsed_ms: Option<u64>,
|
||
#[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<u64>,
|
||
#[serde(default)]
|
||
pub freeze_accumulated_ms: u64,
|
||
#[serde(default)]
|
||
pub freeze_started_at_ms: Option<u64>,
|
||
#[serde(default)]
|
||
pub freeze_until_ms: Option<u64>,
|
||
#[serde(default)]
|
||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub previous_level_tags: Vec<String>,
|
||
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
|
||
pub recommended_next_profile_id: Option<String>,
|
||
#[serde(default = "default_puzzle_next_level_mode")]
|
||
pub next_level_mode: String,
|
||
#[serde(default)]
|
||
pub next_level_profile_id: Option<String>,
|
||
#[serde(default)]
|
||
pub next_level_id: Option<String>,
|
||
#[serde(default)]
|
||
pub recommended_next_works: Vec<PuzzleRecommendedNextWork>,
|
||
#[serde(default)]
|
||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub cover_image_src: Option<String>,
|
||
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<String>,
|
||
pub assistant_reply_text: Option<String>,
|
||
pub stage: PuzzleAgentStage,
|
||
pub progress_percent: u32,
|
||
pub anchor_pack_json: String,
|
||
pub error_message: Option<String>,
|
||
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<String>,
|
||
pub levels_json: Option<String>,
|
||
pub candidates_json: String,
|
||
pub saved_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleSelectCoverImageInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub level_id: Option<String>,
|
||
pub candidate_id: String,
|
||
pub selected_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzlePublishInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub work_id: String,
|
||
pub profile_id: String,
|
||
pub author_display_name: String,
|
||
pub work_title: Option<String>,
|
||
pub work_description: Option<String>,
|
||
pub level_name: Option<String>,
|
||
pub summary: Option<String>,
|
||
pub theme_tags: Option<Vec<String>>,
|
||
pub levels_json: Option<String>,
|
||
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<String>,
|
||
pub cover_image_src: Option<String>,
|
||
pub cover_asset_id: Option<String>,
|
||
pub levels_json: Option<String>,
|
||
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<String>,
|
||
pub started_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunGetInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunSwapInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub first_piece_id: String,
|
||
pub second_piece_id: String,
|
||
pub swapped_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunDragInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub piece_id: String,
|
||
pub target_row: u32,
|
||
pub target_col: u32,
|
||
pub dragged_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunNextLevelInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub advanced_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct 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<String>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleWorksProcedureResult {
|
||
pub ok: bool,
|
||
pub items_json: Option<String>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleWorkProcedureResult {
|
||
pub ok: bool,
|
||
pub item_json: Option<String>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunProcedureResult {
|
||
pub ok: bool,
|
||
pub run_json: Option<String>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[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::<Vec<_>>()
|
||
.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<String>,
|
||
work_description: Option<String>,
|
||
picture_description: Option<String>,
|
||
) -> 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<Vec<PuzzleGeneratedImageCandidate>, 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<PuzzleResultDraft, PuzzleFieldError> {
|
||
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<PuzzleDraftLevel> {
|
||
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<PuzzleResultDraft, PuzzleFieldError> {
|
||
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<PuzzleResultDraft, PuzzleFieldError> {
|
||
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<PuzzleResultPreviewBlocker> {
|
||
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<String>,
|
||
author_display_name: String,
|
||
draft: &PuzzleResultDraft,
|
||
updated_at_micros: i64,
|
||
) -> Result<PuzzleWorkProfile, PuzzleFieldError> {
|
||
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<PuzzleWorkProfile, PuzzleFieldError> {
|
||
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<String>,
|
||
work_description: Option<String>,
|
||
level_name: Option<String>,
|
||
summary: Option<String>,
|
||
theme_tags: Option<Vec<String>>,
|
||
levels: Option<Vec<PuzzleDraftLevel>>,
|
||
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
||
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<PuzzleDraftLevel>,
|
||
theme_tags: &[String],
|
||
) -> Result<Vec<PuzzleDraftLevel>, 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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
set_puzzle_run_paused_at(run, paused, current_unix_ms())
|
||
}
|
||
|
||
pub fn apply_puzzle_freeze_time_at(
|
||
run: &PuzzleRunSnapshot,
|
||
now_ms: u64,
|
||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
apply_puzzle_freeze_time_at(run, current_unix_ms())
|
||
}
|
||
|
||
pub fn extend_failed_puzzle_time_at(
|
||
run: &PuzzleRunSnapshot,
|
||
now_ms: u64,
|
||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
extend_failed_puzzle_time_at(run, current_unix_ms())
|
||
}
|
||
|
||
pub fn build_initial_board(grid_size: u32) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||
build_initial_board_with_seed(grid_size, 0)
|
||
}
|
||
|
||
pub fn build_initial_board_with_seed(
|
||
grid_size: u32,
|
||
shuffle_seed: u64,
|
||
) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzlePieceState>,
|
||
affected_cells: impl IntoIterator<Item = PuzzleCellPosition>,
|
||
selected_piece_id: Option<String>,
|
||
) -> 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::<BTreeSet<_>>();
|
||
let previous_piece_by_id = previous_board
|
||
.pieces
|
||
.iter()
|
||
.map(|piece| (piece.piece_id.clone(), piece))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
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<PuzzleDraftLevel> {
|
||
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<PuzzleDraftLevel> {
|
||
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<usize> {
|
||
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::<Vec<_>>();
|
||
|
||
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::<BTreeSet<_>>();
|
||
let right_set = normalize_theme_tags(right_tags.to_vec())
|
||
.into_iter()
|
||
.collect::<BTreeSet<_>>();
|
||
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<RpgBuildTagSemanticDefinition> {
|
||
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::<f32>().sqrt();
|
||
let right_magnitude = right.iter().map(|value| value * value).sum::<f32>().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::<f32>()
|
||
}
|
||
|
||
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::<f32>();
|
||
total / left.len() as f32
|
||
}
|
||
|
||
fn rpg_build_tag_set_similarity(
|
||
left_tags: &BTreeSet<String>,
|
||
right_tags: &BTreeSet<String>,
|
||
) -> Option<f32> {
|
||
let left_definitions = left_tags
|
||
.iter()
|
||
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
|
||
.collect::<Vec<_>>();
|
||
let right_definitions = right_tags
|
||
.iter()
|
||
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
|
||
.collect::<Vec<_>>();
|
||
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<String>) -> Vec<String> {
|
||
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::<Vec<_>>();
|
||
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<String>,
|
||
work_description: Option<String>,
|
||
picture_description: Option<String>,
|
||
}
|
||
|
||
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<PuzzleFormSeedParts> {
|
||
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<String> {
|
||
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<String> {
|
||
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::<Vec<_>>();
|
||
|
||
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<String> {
|
||
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<PuzzlePieceState> {
|
||
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<PuzzleCellPosition> {
|
||
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<PuzzlePieceState> {
|
||
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::<BTreeMap<_, _>>();
|
||
|
||
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<Vec<PuzzlePieceState>> {
|
||
// 中文注释:大棋盘随机命中“无原图相邻贴边”的概率较低,失败后用确定性排列兜底保证稳定开局。
|
||
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<PuzzleCellPosition> {
|
||
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<PuzzleCellPosition> {
|
||
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<Vec<PuzzlePieceState>> {
|
||
let total = (grid_size * grid_size) as usize;
|
||
let mut piece_order = (0..total as u32).collect::<Vec<_>>();
|
||
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<PuzzleCellPosition>],
|
||
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<PuzzleCellPosition>],
|
||
) -> 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<PuzzlePieceState>,
|
||
selected_piece_id: Option<String>,
|
||
) -> 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<PuzzlePieceState>,
|
||
merged_groups: Vec<PuzzleMergedGroupState>,
|
||
selected_piece_id: Option<String>,
|
||
) -> 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::<BTreeMap<_, _>>();
|
||
|
||
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<PuzzleMergedGroupState>) -> Vec<PuzzleMergedGroupState> {
|
||
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<Item = PuzzleCellPosition>,
|
||
) -> 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<String>,
|
||
) {
|
||
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<PuzzleMergedGroupState> {
|
||
let pieces_by_cell = pieces
|
||
.iter()
|
||
.map(|piece| ((piece.current_row, piece.current_col), piece))
|
||
.collect::<BTreeMap<_, _>>();
|
||
let pieces_by_id = pieces
|
||
.iter()
|
||
.map(|piece| (piece.piece_id.clone(), piece))
|
||
.collect::<BTreeMap<_, _>>();
|
||
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::<Vec<_>>();
|
||
|
||
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<Vec<PuzzleCellPosition>, 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<Vec<PuzzleCellPosition>, PuzzleFieldError> {
|
||
let group_indices = pieces
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, piece)| {
|
||
(piece.merged_group_id.as_deref() == Some(group_id)).then_some(index)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
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::<BTreeSet<_>>();
|
||
let source_positions = group_indices
|
||
.iter()
|
||
.map(|index| (pieces[*index].current_row, pieces[*index].current_col))
|
||
.collect::<Vec<_>>();
|
||
let mut affected_cells = source_positions
|
||
.iter()
|
||
.map(|(row, col)| PuzzleCellPosition {
|
||
row: *row,
|
||
col: *col,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
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::<Vec<_>>();
|
||
let second_positions = second
|
||
.current_level
|
||
.expect("second level")
|
||
.board
|
||
.pieces
|
||
.into_iter()
|
||
.map(|piece| (piece.current_row, piece.current_col))
|
||
.collect::<Vec<_>>();
|
||
|
||
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::<Vec<_>>();
|
||
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);
|
||
}
|
||
}
|