Files
Genarrative/server-rs/crates/module-puzzle/src/lib.rs
2026-05-01 20:29:09 +08:00

4181 lines
145 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
&current_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,
&current_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 {
// 中文注释:失败重开指定的是当前关 levelIdstart_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(&current_profile.theme_tags, &right.theme_tags)
.partial_cmp(&tag_similarity_score(
&current_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(&current_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(&current_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(&current, &["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);
}
}