2439 lines
82 KiB
Rust
2439 lines
82 KiB
Rust
use std::{
|
||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||
error::Error,
|
||
fmt,
|
||
};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use shared_kernel::{normalize_required_string, normalize_string_list};
|
||
#[cfg(feature = "spacetime-types")]
|
||
use spacetimedb::SpacetimeType;
|
||
|
||
pub const PUZZLE_AGENT_SESSION_ID_PREFIX: &str = "puzzle-session-";
|
||
pub const PUZZLE_AGENT_MESSAGE_ID_PREFIX: &str = "puzzle-message-";
|
||
pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-";
|
||
pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-";
|
||
pub const PUZZLE_MIN_TAG_COUNT: usize = 3;
|
||
pub const PUZZLE_MAX_TAG_COUNT: usize = 6;
|
||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum PuzzleAgentStage {
|
||
CollectingAnchors,
|
||
DraftReady,
|
||
ImageRefining,
|
||
ReadyToPublish,
|
||
Published,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum PuzzleAnchorStatus {
|
||
Missing,
|
||
Inferred,
|
||
Confirmed,
|
||
Locked,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum PuzzleAgentMessageRole {
|
||
User,
|
||
Assistant,
|
||
System,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum PuzzleAgentMessageKind {
|
||
Chat,
|
||
Summary,
|
||
ActionResult,
|
||
Warning,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum PuzzlePublicationStatus {
|
||
Draft,
|
||
Published,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum PuzzleRuntimeLevelStatus {
|
||
Playing,
|
||
Cleared,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleAnchorItem {
|
||
pub key: String,
|
||
pub label: String,
|
||
pub value: String,
|
||
pub status: PuzzleAnchorStatus,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleAnchorPack {
|
||
pub theme_promise: PuzzleAnchorItem,
|
||
pub visual_subject: PuzzleAnchorItem,
|
||
pub visual_mood: PuzzleAnchorItem,
|
||
pub composition_hooks: PuzzleAnchorItem,
|
||
pub tags_and_forbidden: PuzzleAnchorItem,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleCreatorIntent {
|
||
pub source_mode: String,
|
||
pub raw_messages_summary: String,
|
||
pub theme_promise: String,
|
||
pub visual_subject: String,
|
||
pub visual_mood: Vec<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 PuzzleResultDraft {
|
||
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,
|
||
}
|
||
|
||
#[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,
|
||
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 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,
|
||
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>,
|
||
pub selected_piece_id: Option<String>,
|
||
pub all_tiles_resolved: bool,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRuntimeLevelSnapshot {
|
||
pub run_id: String,
|
||
pub level_index: u32,
|
||
pub grid_size: u32,
|
||
pub profile_id: String,
|
||
pub level_name: String,
|
||
pub author_display_name: String,
|
||
pub theme_tags: Vec<String>,
|
||
pub cover_image_src: Option<String>,
|
||
pub board: PuzzleBoardSnapshot,
|
||
pub status: PuzzleRuntimeLevelStatus,
|
||
pub started_at_ms: u64,
|
||
pub cleared_at_ms: Option<u64>,
|
||
pub elapsed_ms: Option<u64>,
|
||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunSnapshot {
|
||
pub run_id: String,
|
||
pub entry_profile_id: String,
|
||
pub cleared_level_count: u32,
|
||
pub current_level_index: u32,
|
||
pub current_grid_size: u32,
|
||
pub played_profile_ids: Vec<String>,
|
||
pub previous_level_tags: Vec<String>,
|
||
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
|
||
pub recommended_next_profile_id: Option<String>,
|
||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleAgentSessionCreateInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub seed_text: String,
|
||
pub welcome_message_id: String,
|
||
pub welcome_message_text: String,
|
||
pub created_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleAgentSessionGetInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleAgentMessageSubmitInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub user_message_id: String,
|
||
pub user_message_text: String,
|
||
pub submitted_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleAgentMessageFinalizeInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub assistant_message_id: Option<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 candidates_json: String,
|
||
pub saved_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleSelectCoverImageInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub candidate_id: String,
|
||
pub selected_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzlePublishInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub work_id: String,
|
||
pub profile_id: String,
|
||
pub author_display_name: String,
|
||
pub level_name: Option<String>,
|
||
pub summary: Option<String>,
|
||
pub theme_tags: Option<Vec<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 level_name: String,
|
||
pub summary: String,
|
||
pub theme_tags: Vec<String>,
|
||
pub cover_image_src: Option<String>,
|
||
pub cover_asset_id: 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 PuzzleRunStartInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub profile_id: String,
|
||
pub started_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunGetInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunSwapInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub first_piece_id: String,
|
||
pub second_piece_id: String,
|
||
pub swapped_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunDragInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub piece_id: String,
|
||
pub target_row: u32,
|
||
pub target_col: u32,
|
||
pub dragged_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleRunNextLevelInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub advanced_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleLeaderboardSubmitInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub profile_id: String,
|
||
pub grid_size: u32,
|
||
pub elapsed_ms: u64,
|
||
pub nickname: String,
|
||
pub submitted_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PuzzleAgentSessionProcedureResult {
|
||
pub ok: bool,
|
||
pub session_json: Option<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",
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn empty_anchor_pack() -> PuzzleAnchorPack {
|
||
PuzzleAnchorPack {
|
||
theme_promise: PuzzleAnchorItem {
|
||
key: "themePromise".to_string(),
|
||
label: "题材承诺".to_string(),
|
||
value: String::new(),
|
||
status: PuzzleAnchorStatus::Missing,
|
||
},
|
||
visual_subject: PuzzleAnchorItem {
|
||
key: "visualSubject".to_string(),
|
||
label: "画面主体".to_string(),
|
||
value: String::new(),
|
||
status: PuzzleAnchorStatus::Missing,
|
||
},
|
||
visual_mood: PuzzleAnchorItem {
|
||
key: "visualMood".to_string(),
|
||
label: "视觉气质".to_string(),
|
||
value: String::new(),
|
||
status: PuzzleAnchorStatus::Missing,
|
||
},
|
||
composition_hooks: PuzzleAnchorItem {
|
||
key: "compositionHooks".to_string(),
|
||
label: "拼图记忆点".to_string(),
|
||
value: String::new(),
|
||
status: PuzzleAnchorStatus::Missing,
|
||
},
|
||
tags_and_forbidden: PuzzleAnchorItem {
|
||
key: "tagsAndForbidden".to_string(),
|
||
label: "标签与禁忌".to_string(),
|
||
value: String::new(),
|
||
status: PuzzleAnchorStatus::Missing,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> PuzzleAnchorPack {
|
||
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
|
||
.or_else(|| normalize_required_string(seed_text))
|
||
.unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string());
|
||
let mut pack = empty_anchor_pack();
|
||
pack.theme_promise.value = infer_theme_promise(&source);
|
||
pack.theme_promise.status = PuzzleAnchorStatus::Inferred;
|
||
pack.visual_subject.value = infer_visual_subject(&source);
|
||
pack.visual_subject.status = PuzzleAnchorStatus::Inferred;
|
||
pack.visual_mood.value = infer_visual_mood(&source);
|
||
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
|
||
pack.composition_hooks.value = infer_composition_hooks(&source);
|
||
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
|
||
pack.tags_and_forbidden.value = infer_tags_and_forbidden(&source);
|
||
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
|
||
pack
|
||
}
|
||
|
||
pub fn build_creator_intent(
|
||
anchor_pack: &PuzzleAnchorPack,
|
||
messages: &[PuzzleAgentMessageSnapshot],
|
||
) -> PuzzleCreatorIntent {
|
||
PuzzleCreatorIntent {
|
||
source_mode: "agent_chat".to_string(),
|
||
raw_messages_summary: messages
|
||
.iter()
|
||
.rev()
|
||
.take(4)
|
||
.map(|entry| entry.text.clone())
|
||
.collect::<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 {
|
||
let creator_intent = build_creator_intent(anchor_pack, messages);
|
||
let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone());
|
||
let level_name = build_level_name(anchor_pack, &normalized_tags);
|
||
PuzzleResultDraft {
|
||
level_name,
|
||
summary: format!(
|
||
"{},主体是{},氛围偏{}。",
|
||
fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"),
|
||
fallback_text(&anchor_pack.visual_subject.value, "画面主体"),
|
||
fallback_text(&anchor_pack.visual_mood.value, "温暖")
|
||
),
|
||
theme_tags: normalized_tags,
|
||
forbidden_directives: creator_intent.forbidden_directives.clone(),
|
||
creator_intent: Some(creator_intent),
|
||
anchor_pack: anchor_pack.clone(),
|
||
candidates: Vec::new(),
|
||
selected_candidate_id: None,
|
||
cover_image_src: None,
|
||
cover_asset_id: None,
|
||
generation_status: "idle".to_string(),
|
||
}
|
||
}
|
||
|
||
pub fn build_generated_candidates(
|
||
session_id: &str,
|
||
prompt_text: Option<&str>,
|
||
draft: &PuzzleResultDraft,
|
||
candidate_count: u32,
|
||
now_micros: i64,
|
||
) -> Result<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 build_result_preview(
|
||
draft: &PuzzleResultDraft,
|
||
author_display_name: Option<&str>,
|
||
) -> PuzzleResultPreviewEnvelope {
|
||
let blockers = validate_publish_requirements(draft, author_display_name);
|
||
PuzzleResultPreviewEnvelope {
|
||
draft: draft.clone(),
|
||
blockers,
|
||
quality_findings: Vec::new(),
|
||
publish_ready: validate_publish_requirements(draft, author_display_name).is_empty(),
|
||
}
|
||
}
|
||
|
||
pub fn validate_publish_requirements(
|
||
draft: &PuzzleResultDraft,
|
||
author_display_name: Option<&str>,
|
||
) -> Vec<PuzzleResultPreviewBlocker> {
|
||
let mut blockers = Vec::new();
|
||
if normalize_required_string(&draft.level_name).is_none() {
|
||
blockers.push(PuzzleResultPreviewBlocker {
|
||
id: "missing-level-name".to_string(),
|
||
code: "MISSING_LEVEL_NAME".to_string(),
|
||
message: "关卡名不能为空".to_string(),
|
||
});
|
||
}
|
||
if draft
|
||
.cover_image_src
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.unwrap_or("")
|
||
.is_empty()
|
||
{
|
||
blockers.push(PuzzleResultPreviewBlocker {
|
||
id: "missing-cover-image".to_string(),
|
||
code: "MISSING_COVER_IMAGE".to_string(),
|
||
message: "正式拼图图片尚未确定".to_string(),
|
||
});
|
||
}
|
||
if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|
||
|| draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT
|
||
{
|
||
blockers.push(PuzzleResultPreviewBlocker {
|
||
id: "invalid-tag-count".to_string(),
|
||
code: "INVALID_TAG_COUNT".to_string(),
|
||
message: "正式标签数量必须在 3 到 6 之间".to_string(),
|
||
});
|
||
}
|
||
if normalize_required_string(author_display_name.unwrap_or("")).is_none() {
|
||
blockers.push(PuzzleResultPreviewBlocker {
|
||
id: "missing-author".to_string(),
|
||
code: "MISSING_AUTHOR".to_string(),
|
||
message: "作者信息不可读".to_string(),
|
||
});
|
||
}
|
||
blockers
|
||
}
|
||
|
||
pub fn create_work_profile(
|
||
work_id: String,
|
||
profile_id: String,
|
||
owner_user_id: String,
|
||
source_session_id: Option<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 preview = build_result_preview(draft, Some(&author_display_name));
|
||
Ok(PuzzleWorkProfile {
|
||
work_id,
|
||
profile_id,
|
||
owner_user_id,
|
||
source_session_id,
|
||
author_display_name,
|
||
level_name: draft.level_name.clone(),
|
||
summary: draft.summary.clone(),
|
||
theme_tags: normalize_theme_tags(draft.theme_tags.clone()),
|
||
cover_image_src: draft.cover_image_src.clone(),
|
||
cover_asset_id: draft.cover_asset_id.clone(),
|
||
publication_status: PuzzlePublicationStatus::Draft,
|
||
updated_at_micros,
|
||
published_at_micros: None,
|
||
play_count: 0,
|
||
remix_count: 0,
|
||
like_count: 0,
|
||
publish_ready: preview.publish_ready,
|
||
anchor_pack: draft.anchor_pack.clone(),
|
||
})
|
||
}
|
||
|
||
pub fn publish_work_profile(
|
||
mut profile: PuzzleWorkProfile,
|
||
draft: &PuzzleResultDraft,
|
||
published_at_micros: i64,
|
||
) -> Result<PuzzleWorkProfile, PuzzleFieldError> {
|
||
if !validate_publish_requirements(draft, Some(&profile.author_display_name)).is_empty() {
|
||
return Err(PuzzleFieldError::InvalidOperation);
|
||
}
|
||
profile.level_name = draft.level_name.clone();
|
||
profile.summary = draft.summary.clone();
|
||
profile.theme_tags = normalize_theme_tags(draft.theme_tags.clone());
|
||
profile.cover_image_src = draft.cover_image_src.clone();
|
||
profile.cover_asset_id = draft.cover_asset_id.clone();
|
||
profile.publication_status = PuzzlePublicationStatus::Published;
|
||
profile.publish_ready = true;
|
||
profile.updated_at_micros = published_at_micros;
|
||
profile.published_at_micros = Some(published_at_micros);
|
||
Ok(profile)
|
||
}
|
||
|
||
/// 在发布前把结果页的轻量编辑字段覆盖回草稿真相。
|
||
/// 这里只允许覆盖 PRD 明确要求的关卡名、摘要与标签,不额外扩到更多结果页元数据。
|
||
pub fn apply_publish_overrides_to_draft(
|
||
draft: &PuzzleResultDraft,
|
||
level_name: Option<String>,
|
||
summary: Option<String>,
|
||
theme_tags: Option<Vec<String>>,
|
||
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
||
let mut next_draft = draft.clone();
|
||
|
||
if let Some(next_level_name) = level_name
|
||
&& let Some(normalized_level_name) = normalize_required_string(&next_level_name)
|
||
{
|
||
next_draft.level_name = normalized_level_name;
|
||
}
|
||
|
||
if let Some(next_summary) = summary
|
||
&& let Some(normalized_summary) = normalize_required_string(&next_summary)
|
||
{
|
||
next_draft.summary = normalized_summary;
|
||
}
|
||
|
||
if let Some(next_theme_tags) = theme_tags {
|
||
let normalized_theme_tags = normalize_theme_tags(next_theme_tags);
|
||
if normalized_theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|
||
|| normalized_theme_tags.len() > PUZZLE_MAX_TAG_COUNT
|
||
{
|
||
return Err(PuzzleFieldError::InvalidTagCount);
|
||
}
|
||
next_draft.theme_tags = normalized_theme_tags;
|
||
}
|
||
|
||
Ok(next_draft)
|
||
}
|
||
|
||
pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 {
|
||
if cleared_level_count >= 3 { 4 } else { 3 }
|
||
}
|
||
|
||
pub fn build_initial_board(grid_size: u32) -> Result<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 !matches!(grid_size, 3 | 4) {
|
||
return Err(PuzzleFieldError::InvalidGridSize);
|
||
}
|
||
|
||
let pieces = build_initial_pieces_without_correct_neighbors(grid_size, shuffle_seed);
|
||
|
||
Ok(rebuild_board_snapshot(grid_size, pieces, None))
|
||
}
|
||
|
||
pub fn start_run(
|
||
run_id: String,
|
||
entry_profile: &PuzzleWorkProfile,
|
||
cleared_level_count: u32,
|
||
) -> Result<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(run_id, entry_profile, cleared_level_count, shuffle_seed)
|
||
}
|
||
|
||
pub fn start_run_with_shuffle_seed(
|
||
run_id: String,
|
||
entry_profile: &PuzzleWorkProfile,
|
||
cleared_level_count: u32,
|
||
shuffle_seed: u64,
|
||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
||
let started_at_ms = current_unix_ms();
|
||
Ok(PuzzleRunSnapshot {
|
||
run_id: run_id.clone(),
|
||
entry_profile_id: entry_profile.profile_id.clone(),
|
||
cleared_level_count,
|
||
current_level_index: cleared_level_count + 1,
|
||
current_grid_size: grid_size,
|
||
played_profile_ids: vec![entry_profile.profile_id.clone()],
|
||
previous_level_tags: entry_profile.theme_tags.clone(),
|
||
current_level: Some(PuzzleRuntimeLevelSnapshot {
|
||
run_id,
|
||
level_index: cleared_level_count + 1,
|
||
grid_size,
|
||
profile_id: entry_profile.profile_id.clone(),
|
||
level_name: entry_profile.level_name.clone(),
|
||
author_display_name: entry_profile.author_display_name.clone(),
|
||
theme_tags: entry_profile.theme_tags.clone(),
|
||
cover_image_src: entry_profile.cover_image_src.clone(),
|
||
board,
|
||
status: PuzzleRuntimeLevelStatus::Playing,
|
||
started_at_ms,
|
||
cleared_at_ms: None,
|
||
elapsed_ms: None,
|
||
leaderboard_entries: Vec::new(),
|
||
}),
|
||
recommended_next_profile_id: None,
|
||
leaderboard_entries: Vec::new(),
|
||
})
|
||
}
|
||
|
||
pub fn swap_pieces(
|
||
run: &PuzzleRunSnapshot,
|
||
first_piece_id: &str,
|
||
second_piece_id: &str,
|
||
) -> Result<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 current_level = run
|
||
.current_level
|
||
.clone()
|
||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||
if current_level.status == PuzzleRuntimeLevelStatus::Cleared {
|
||
return Err(PuzzleFieldError::InvalidOperation);
|
||
}
|
||
let mut pieces = current_level.board.pieces.clone();
|
||
let first_index = pieces
|
||
.iter()
|
||
.position(|piece| piece.piece_id == first_piece_id)
|
||
.ok_or(PuzzleFieldError::MissingPieceId)?;
|
||
let second_index = pieces
|
||
.iter()
|
||
.position(|piece| piece.piece_id == second_piece_id)
|
||
.ok_or(PuzzleFieldError::MissingPieceId)?;
|
||
|
||
let (first_row, first_col) = (
|
||
pieces[first_index].current_row,
|
||
pieces[first_index].current_col,
|
||
);
|
||
let (second_row, second_col) = (
|
||
pieces[second_index].current_row,
|
||
pieces[second_index].current_col,
|
||
);
|
||
pieces[first_index].current_row = second_row;
|
||
pieces[first_index].current_col = second_col;
|
||
pieces[second_index].current_row = first_row;
|
||
pieces[second_index].current_col = first_col;
|
||
|
||
let affected_cells = [
|
||
PuzzleCellPosition {
|
||
row: first_row,
|
||
col: first_col,
|
||
},
|
||
PuzzleCellPosition {
|
||
row: second_row,
|
||
col: second_col,
|
||
},
|
||
];
|
||
let next_board = rebuild_board_snapshot_for_affected_cells(
|
||
current_level.grid_size,
|
||
¤t_level.board,
|
||
pieces,
|
||
affected_cells,
|
||
None,
|
||
);
|
||
Ok(with_next_board(run, next_board))
|
||
}
|
||
|
||
pub fn drag_piece_or_group(
|
||
run: &PuzzleRunSnapshot,
|
||
piece_id: &str,
|
||
target_row: u32,
|
||
target_col: u32,
|
||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||
let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
|
||
let current_level = run
|
||
.current_level
|
||
.clone()
|
||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||
if current_level.status == PuzzleRuntimeLevelStatus::Cleared {
|
||
return Err(PuzzleFieldError::InvalidOperation);
|
||
}
|
||
let grid_size = current_level.grid_size;
|
||
if target_row >= grid_size || target_col >= grid_size {
|
||
return Err(PuzzleFieldError::InvalidTargetCell);
|
||
}
|
||
|
||
let mut pieces = current_level.board.pieces.clone();
|
||
let piece_index = pieces
|
||
.iter()
|
||
.position(|piece| piece.piece_id == piece_id)
|
||
.ok_or(PuzzleFieldError::MissingPieceId)?;
|
||
let source_group_id = pieces[piece_index].merged_group_id.clone();
|
||
|
||
let operation_cells = match source_group_id {
|
||
Some(group_id) => drag_group(&mut pieces, &group_id, target_row, target_col, grid_size)?,
|
||
None => drag_single_piece(&mut pieces, piece_index, target_row, target_col)?,
|
||
};
|
||
|
||
let next_board = rebuild_board_snapshot_for_affected_cells(
|
||
grid_size,
|
||
¤t_level.board,
|
||
pieces,
|
||
operation_cells,
|
||
None,
|
||
);
|
||
Ok(with_next_board(run, next_board))
|
||
}
|
||
|
||
pub fn rebuild_board_snapshot_for_affected_cells(
|
||
grid_size: u32,
|
||
previous_board: &PuzzleBoardSnapshot,
|
||
pieces: Vec<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> {
|
||
let current_level = run
|
||
.current_level
|
||
.clone()
|
||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
|
||
return Err(PuzzleFieldError::InvalidOperation);
|
||
}
|
||
|
||
let next_cleared_count = run.cleared_level_count;
|
||
let next_grid_size = resolve_puzzle_grid_size(next_cleared_count);
|
||
let shuffle_seed = puzzle_shuffle_seed(
|
||
&run.run_id,
|
||
&next_profile.profile_id,
|
||
run.current_level_index + 1,
|
||
next_grid_size,
|
||
);
|
||
let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?;
|
||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||
played_profile_ids.push(next_profile.profile_id.clone());
|
||
|
||
Ok(PuzzleRunSnapshot {
|
||
run_id: run.run_id.clone(),
|
||
entry_profile_id: run.entry_profile_id.clone(),
|
||
cleared_level_count: next_cleared_count,
|
||
current_level_index: run.current_level_index + 1,
|
||
current_grid_size: next_grid_size,
|
||
played_profile_ids,
|
||
previous_level_tags: next_profile.theme_tags.clone(),
|
||
current_level: Some(PuzzleRuntimeLevelSnapshot {
|
||
run_id: run.run_id.clone(),
|
||
level_index: run.current_level_index + 1,
|
||
grid_size: next_grid_size,
|
||
profile_id: next_profile.profile_id.clone(),
|
||
level_name: next_profile.level_name.clone(),
|
||
author_display_name: next_profile.author_display_name.clone(),
|
||
theme_tags: next_profile.theme_tags.clone(),
|
||
cover_image_src: next_profile.cover_image_src.clone(),
|
||
board: next_board,
|
||
status: PuzzleRuntimeLevelStatus::Playing,
|
||
started_at_ms: current_unix_ms(),
|
||
cleared_at_ms: None,
|
||
elapsed_ms: None,
|
||
leaderboard_entries: Vec::new(),
|
||
}),
|
||
recommended_next_profile_id: None,
|
||
leaderboard_entries: Vec::new(),
|
||
})
|
||
}
|
||
|
||
pub fn select_next_profile<'a>(
|
||
current_profile: &PuzzleWorkProfile,
|
||
played_profile_ids: &[String],
|
||
candidates: &'a [PuzzleWorkProfile],
|
||
) -> Option<&'a PuzzleWorkProfile> {
|
||
let mut available = candidates
|
||
.iter()
|
||
.filter(|candidate| {
|
||
candidate.publication_status == PuzzlePublicationStatus::Published
|
||
&& candidate.cover_image_src.is_some()
|
||
&& !candidate.theme_tags.is_empty()
|
||
&& candidate.profile_id != current_profile.profile_id
|
||
})
|
||
.collect::<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.into_iter().max_by(|left, right| {
|
||
let left_score = recommendation_score(current_profile, left);
|
||
let right_score = recommendation_score(current_profile, right);
|
||
left_score
|
||
.partial_cmp(&right_score)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
.then_with(|| {
|
||
tag_similarity_score(¤t_profile.theme_tags, &left.theme_tags)
|
||
.partial_cmp(&tag_similarity_score(
|
||
¤t_profile.theme_tags,
|
||
&right.theme_tags,
|
||
))
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
})
|
||
.then_with(|| right.play_count.cmp(&left.play_count))
|
||
.then_with(|| left.updated_at_micros.cmp(&right.updated_at_micros))
|
||
})
|
||
}
|
||
|
||
pub fn recommendation_score(
|
||
current_profile: &PuzzleWorkProfile,
|
||
candidate: &PuzzleWorkProfile,
|
||
) -> f32 {
|
||
let tag_similarity = tag_similarity_score(¤t_profile.theme_tags, &candidate.theme_tags);
|
||
let same_author_score = if current_profile.owner_user_id == candidate.owner_user_id {
|
||
1.0
|
||
} else {
|
||
0.0
|
||
};
|
||
tag_similarity * 0.7 + same_author_score * 0.3
|
||
}
|
||
|
||
pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32 {
|
||
let left_set = normalize_theme_tags(left_tags.to_vec())
|
||
.into_iter()
|
||
.collect::<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 {
|
||
intersection / union
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
fn extract_forbidden_directive(source: &str) -> String {
|
||
if let Some((_, tail)) = source.split_once(';') {
|
||
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
|
||
}
|
||
"禁止标题字".to_string()
|
||
}
|
||
|
||
fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String {
|
||
if let Some(tag) = normalized_tags.first() {
|
||
return format!("{tag}拼图");
|
||
}
|
||
if let Some(subject) = normalize_required_string(&anchor_pack.visual_subject.value) {
|
||
return subject.chars().take(8).collect::<String>();
|
||
}
|
||
"奇景拼图".to_string()
|
||
}
|
||
|
||
fn fallback_text(value: &str, fallback: &str) -> String {
|
||
normalize_required_string(value).unwrap_or_else(|| fallback.to_string())
|
||
}
|
||
|
||
fn split_phrase_list(value: &str) -> Vec<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_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_original_neighbor_free_pieces(
|
||
grid_size: u32,
|
||
shuffle_seed: u64,
|
||
) -> Option<Vec<PuzzlePieceState>> {
|
||
let total = (grid_size * grid_size) as usize;
|
||
let mut piece_order = (0..total as u32).collect::<Vec<_>>();
|
||
sort_indices_by_seed(&mut piece_order, shuffle_seed ^ 0xa076_1d64_78bd_642f);
|
||
let mut cell_order = build_correct_positions(grid_size);
|
||
sort_cells_by_seed(&mut cell_order, shuffle_seed ^ 0xe703_7ed1_a0b4_28db);
|
||
|
||
let mut placements = vec![None; total];
|
||
let mut used_cells = BTreeSet::new();
|
||
if place_neighbor_free_piece(
|
||
grid_size,
|
||
&piece_order,
|
||
&cell_order,
|
||
0,
|
||
&mut placements,
|
||
&mut used_cells,
|
||
) {
|
||
Some(
|
||
placements
|
||
.into_iter()
|
||
.enumerate()
|
||
.filter_map(|(index, current)| {
|
||
current.map(|current| PuzzlePieceState {
|
||
piece_id: format!("piece-{index}"),
|
||
correct_row: index as u32 / grid_size,
|
||
correct_col: index as u32 % grid_size,
|
||
current_row: current.row,
|
||
current_col: current.col,
|
||
merged_group_id: None,
|
||
})
|
||
})
|
||
.collect(),
|
||
)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn place_neighbor_free_piece(
|
||
grid_size: u32,
|
||
piece_order: &[u32],
|
||
cell_order: &[PuzzleCellPosition],
|
||
depth: usize,
|
||
placements: &mut [Option<PuzzleCellPosition>],
|
||
used_cells: &mut BTreeSet<(u32, u32)>,
|
||
) -> bool {
|
||
let Some(piece_index) = piece_order.get(depth).copied() else {
|
||
return true;
|
||
};
|
||
|
||
for cell in cell_order {
|
||
if used_cells.contains(&(cell.row, cell.col)) {
|
||
continue;
|
||
}
|
||
if cell.row == piece_index / grid_size && cell.col == piece_index % grid_size {
|
||
continue;
|
||
}
|
||
if violates_original_neighbor_free_rule(grid_size, piece_index, cell.clone(), placements) {
|
||
continue;
|
||
}
|
||
|
||
placements[piece_index as usize] = Some(cell.clone());
|
||
used_cells.insert((cell.row, cell.col));
|
||
if place_neighbor_free_piece(
|
||
grid_size,
|
||
piece_order,
|
||
cell_order,
|
||
depth + 1,
|
||
placements,
|
||
used_cells,
|
||
) {
|
||
return true;
|
||
}
|
||
used_cells.remove(&(cell.row, cell.col));
|
||
placements[piece_index as usize] = None;
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
fn violates_original_neighbor_free_rule(
|
||
grid_size: u32,
|
||
piece_index: u32,
|
||
cell: PuzzleCellPosition,
|
||
placements: &[Option<PuzzleCellPosition>],
|
||
) -> bool {
|
||
placements
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(placed_index, placed_cell)| {
|
||
placed_cell
|
||
.as_ref()
|
||
.map(|placed_cell| (placed_index as u32, placed_cell))
|
||
})
|
||
.any(|(placed_index, placed_cell)| {
|
||
let original_neighbors = (piece_index / grid_size).abs_diff(placed_index / grid_size)
|
||
+ (piece_index % grid_size).abs_diff(placed_index % grid_size)
|
||
== 1;
|
||
let current_neighbors =
|
||
cell.row.abs_diff(placed_cell.row) + cell.col.abs_diff(placed_cell.col) == 1;
|
||
original_neighbors && current_neighbors
|
||
})
|
||
}
|
||
|
||
fn sort_indices_by_seed(indices: &mut [u32], seed: u64) {
|
||
indices.sort_by_key(|index| seeded_order_key(seed, u64::from(*index)));
|
||
}
|
||
|
||
fn sort_cells_by_seed(cells: &mut [PuzzleCellPosition], seed: u64) {
|
||
cells.sort_by_key(|cell| seeded_order_key(seed, u64::from(cell.row * 16 + cell.col)));
|
||
}
|
||
|
||
fn seeded_order_key(seed: u64, value: u64) -> u64 {
|
||
let mut state = seed ^ value.wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||
state ^= state >> 30;
|
||
state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||
state ^= state >> 27;
|
||
state = state.wrapping_mul(0x94d0_49bb_1331_11eb);
|
||
state ^ (state >> 31)
|
||
}
|
||
|
||
fn rebuild_board_snapshot(
|
||
grid_size: u32,
|
||
pieces: Vec<PuzzlePieceState>,
|
||
selected_piece_id: Option<String>,
|
||
) -> PuzzleBoardSnapshot {
|
||
let merged_groups = resolve_merged_groups(&pieces);
|
||
rebuild_board_snapshot_with_groups(grid_size, pieces, merged_groups, selected_piece_id)
|
||
}
|
||
|
||
fn rebuild_board_snapshot_with_groups(
|
||
grid_size: u32,
|
||
mut pieces: Vec<PuzzlePieceState>,
|
||
merged_groups: Vec<PuzzleMergedGroupState>,
|
||
selected_piece_id: Option<String>,
|
||
) -> PuzzleBoardSnapshot {
|
||
let merged_groups = normalize_group_ids(merged_groups);
|
||
let group_by_piece = merged_groups
|
||
.iter()
|
||
.flat_map(|group| {
|
||
group
|
||
.piece_ids
|
||
.iter()
|
||
.cloned()
|
||
.map(|piece_id| (piece_id, group.group_id.clone()))
|
||
})
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
for piece in &mut pieces {
|
||
piece.merged_group_id = group_by_piece.get(&piece.piece_id).cloned();
|
||
}
|
||
|
||
let all_pieces_in_correct_cells = pieces.iter().all(|piece| {
|
||
piece.correct_row == piece.current_row && piece.correct_col == piece.current_col
|
||
});
|
||
let all_pieces_merged_into_one_group = merged_groups
|
||
.iter()
|
||
.any(|group| group.piece_ids.len() == pieces.len() && pieces.len() > 1);
|
||
let all_tiles_resolved = all_pieces_in_correct_cells || all_pieces_merged_into_one_group;
|
||
|
||
PuzzleBoardSnapshot {
|
||
rows: grid_size,
|
||
cols: grid_size,
|
||
pieces,
|
||
merged_groups,
|
||
selected_piece_id,
|
||
all_tiles_resolved,
|
||
}
|
||
}
|
||
|
||
fn normalize_group_ids(groups: Vec<PuzzleMergedGroupState>) -> Vec<PuzzleMergedGroupState> {
|
||
groups
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(index, group)| PuzzleMergedGroupState {
|
||
group_id: format!("group-{}", index + 1),
|
||
..group
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn expand_affected_cells(
|
||
grid_size: u32,
|
||
cells: impl IntoIterator<Item = PuzzleCellPosition>,
|
||
) -> BTreeSet<(u32, u32)> {
|
||
let mut scope = BTreeSet::new();
|
||
for cell in cells {
|
||
if cell.row >= grid_size || cell.col >= grid_size {
|
||
continue;
|
||
}
|
||
scope.insert((cell.row, cell.col));
|
||
for (row, col) in neighbor_cells(cell.row, cell.col) {
|
||
if row < grid_size && col < grid_size {
|
||
scope.insert((row, col));
|
||
}
|
||
}
|
||
}
|
||
scope
|
||
}
|
||
|
||
fn add_previous_group_piece_ids(
|
||
previous_board: &PuzzleBoardSnapshot,
|
||
group_id: &str,
|
||
piece_ids: &mut BTreeSet<String>,
|
||
) {
|
||
if let Some(group) = previous_board
|
||
.merged_groups
|
||
.iter()
|
||
.find(|group| group.group_id == group_id)
|
||
{
|
||
piece_ids.extend(group.piece_ids.iter().cloned());
|
||
}
|
||
}
|
||
|
||
fn resolve_merged_groups(pieces: &[PuzzlePieceState]) -> Vec<PuzzleMergedGroupState> {
|
||
let pieces_by_cell = pieces
|
||
.iter()
|
||
.map(|piece| ((piece.current_row, piece.current_col), piece))
|
||
.collect::<BTreeMap<_, _>>();
|
||
let pieces_by_id = pieces
|
||
.iter()
|
||
.map(|piece| (piece.piece_id.clone(), piece))
|
||
.collect::<BTreeMap<_, _>>();
|
||
let mut visited = BTreeSet::new();
|
||
let mut groups = Vec::new();
|
||
|
||
for piece in pieces {
|
||
if visited.contains(&piece.piece_id) {
|
||
continue;
|
||
}
|
||
|
||
let mut queue = VecDeque::from([piece.piece_id.clone()]);
|
||
let mut collected_ids = Vec::new();
|
||
|
||
while let Some(current_piece_id) = queue.pop_front() {
|
||
if !visited.insert(current_piece_id.clone()) {
|
||
continue;
|
||
}
|
||
let current_piece = match pieces_by_id.get(¤t_piece_id) {
|
||
Some(value) => *value,
|
||
None => continue,
|
||
};
|
||
collected_ids.push(current_piece_id.clone());
|
||
|
||
for (neighbor_row, neighbor_col) in
|
||
neighbor_cells(current_piece.current_row, current_piece.current_col)
|
||
{
|
||
if let Some(neighbor_piece) = pieces_by_cell.get(&(neighbor_row, neighbor_col))
|
||
&& are_correct_neighbors(current_piece, neighbor_piece)
|
||
{
|
||
queue.push_back(neighbor_piece.piece_id.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
if collected_ids.len() <= 1 {
|
||
continue;
|
||
}
|
||
|
||
let occupied_cells = collected_ids
|
||
.iter()
|
||
.filter_map(|piece_id| pieces_by_id.get(piece_id).copied())
|
||
.map(|piece| PuzzleCellPosition {
|
||
row: piece.current_row,
|
||
col: piece.current_col,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
groups.push(PuzzleMergedGroupState {
|
||
group_id: format!("group-{}", groups.len() + 1),
|
||
piece_ids: collected_ids,
|
||
occupied_cells,
|
||
});
|
||
}
|
||
|
||
groups
|
||
}
|
||
|
||
fn neighbor_cells(row: u32, col: u32) -> Vec<(u32, u32)> {
|
||
let mut neighbors = Vec::new();
|
||
if row > 0 {
|
||
neighbors.push((row - 1, col));
|
||
}
|
||
neighbors.push((row + 1, col));
|
||
if col > 0 {
|
||
neighbors.push((row, col - 1));
|
||
}
|
||
neighbors.push((row, col + 1));
|
||
neighbors
|
||
}
|
||
|
||
fn are_correct_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool {
|
||
let current_row_delta = right.current_row as i32 - left.current_row as i32;
|
||
let current_col_delta = right.current_col as i32 - left.current_col as i32;
|
||
let correct_row_delta = right.correct_row as i32 - left.correct_row as i32;
|
||
let correct_col_delta = right.correct_col as i32 - left.correct_col as i32;
|
||
(current_row_delta.abs() + current_col_delta.abs()) == 1
|
||
&& current_row_delta == correct_row_delta
|
||
&& current_col_delta == correct_col_delta
|
||
}
|
||
|
||
fn drag_single_piece(
|
||
pieces: &mut [PuzzlePieceState],
|
||
piece_index: usize,
|
||
target_row: u32,
|
||
target_col: u32,
|
||
) -> Result<Vec<PuzzleCellPosition>, PuzzleFieldError> {
|
||
let target_index = pieces
|
||
.iter()
|
||
.position(|piece| piece.current_row == target_row && piece.current_col == target_col)
|
||
.ok_or(PuzzleFieldError::InvalidTargetCell)?;
|
||
|
||
let mut affected_cells = vec![
|
||
PuzzleCellPosition {
|
||
row: pieces[piece_index].current_row,
|
||
col: pieces[piece_index].current_col,
|
||
},
|
||
PuzzleCellPosition {
|
||
row: target_row,
|
||
col: target_col,
|
||
},
|
||
];
|
||
|
||
if let Some(target_group_id) = pieces[target_index].merged_group_id.clone() {
|
||
for piece in pieces
|
||
.iter_mut()
|
||
.filter(|piece| piece.merged_group_id.as_deref() == Some(target_group_id.as_str()))
|
||
{
|
||
affected_cells.push(PuzzleCellPosition {
|
||
row: piece.current_row,
|
||
col: piece.current_col,
|
||
});
|
||
piece.merged_group_id = None;
|
||
}
|
||
}
|
||
|
||
let (source_row, source_col) = (
|
||
pieces[piece_index].current_row,
|
||
pieces[piece_index].current_col,
|
||
);
|
||
pieces[piece_index].current_row = target_row;
|
||
pieces[piece_index].current_col = target_col;
|
||
if target_index != piece_index {
|
||
pieces[target_index].current_row = source_row;
|
||
pieces[target_index].current_col = source_col;
|
||
}
|
||
Ok(affected_cells)
|
||
}
|
||
|
||
fn drag_group(
|
||
pieces: &mut [PuzzlePieceState],
|
||
group_id: &str,
|
||
target_row: u32,
|
||
target_col: u32,
|
||
grid_size: u32,
|
||
) -> Result<Vec<PuzzleCellPosition>, PuzzleFieldError> {
|
||
let group_indices = pieces
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, piece)| {
|
||
(piece.merged_group_id.as_deref() == Some(group_id)).then_some(index)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if group_indices.is_empty() {
|
||
return Err(PuzzleFieldError::InvalidOperation);
|
||
}
|
||
|
||
let anchor_piece = &pieces[group_indices[0]];
|
||
let row_offset = target_row as i32 - anchor_piece.current_row as i32;
|
||
let col_offset = target_col as i32 - anchor_piece.current_col as i32;
|
||
let mut target_positions = Vec::new();
|
||
for &index in &group_indices {
|
||
let next_row = pieces[index].current_row as i32 + row_offset;
|
||
let next_col = pieces[index].current_col as i32 + col_offset;
|
||
if next_row < 0
|
||
|| next_col < 0
|
||
|| next_row >= grid_size as i32
|
||
|| next_col >= grid_size as i32
|
||
{
|
||
return Err(PuzzleFieldError::InvalidTargetCell);
|
||
}
|
||
target_positions.push((index, next_row as u32, next_col as u32));
|
||
}
|
||
|
||
let moving_piece_ids = group_indices
|
||
.iter()
|
||
.map(|index| pieces[*index].piece_id.clone())
|
||
.collect::<BTreeSet<_>>();
|
||
let source_positions = group_indices
|
||
.iter()
|
||
.map(|index| (pieces[*index].current_row, pieces[*index].current_col))
|
||
.collect::<Vec<_>>();
|
||
let mut affected_cells = source_positions
|
||
.iter()
|
||
.map(|(row, col)| PuzzleCellPosition {
|
||
row: *row,
|
||
col: *col,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
for (index, next_row, next_col) in &target_positions {
|
||
affected_cells.push(PuzzleCellPosition {
|
||
row: *next_row,
|
||
col: *next_col,
|
||
});
|
||
if let Some(target_piece_index) = pieces.iter().position(|piece| {
|
||
piece.current_row == *next_row
|
||
&& piece.current_col == *next_col
|
||
&& !moving_piece_ids.contains(&piece.piece_id)
|
||
}) {
|
||
let fallback = source_positions
|
||
.iter()
|
||
.find(|position| {
|
||
!target_positions
|
||
.iter()
|
||
.any(|(_, row, col)| row == &position.0 && col == &position.1)
|
||
})
|
||
.copied()
|
||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||
pieces[target_piece_index].merged_group_id = None;
|
||
affected_cells.push(PuzzleCellPosition {
|
||
row: pieces[target_piece_index].current_row,
|
||
col: pieces[target_piece_index].current_col,
|
||
});
|
||
affected_cells.push(PuzzleCellPosition {
|
||
row: fallback.0,
|
||
col: fallback.1,
|
||
});
|
||
pieces[target_piece_index].current_row = fallback.0;
|
||
pieces[target_piece_index].current_col = fallback.1;
|
||
}
|
||
pieces[*index].current_row = *next_row;
|
||
pieces[*index].current_col = *next_col;
|
||
}
|
||
|
||
Ok(affected_cells)
|
||
}
|
||
|
||
fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot {
|
||
let mut next_run = run.clone();
|
||
let is_cleared = next_board.all_tiles_resolved;
|
||
let next_level_status = if is_cleared {
|
||
PuzzleRuntimeLevelStatus::Cleared
|
||
} else {
|
||
PuzzleRuntimeLevelStatus::Playing
|
||
};
|
||
|
||
if let Some(current_level) = next_run.current_level.as_mut() {
|
||
current_level.board = next_board;
|
||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
|
||
let cleared_at_ms = current_unix_ms();
|
||
current_level.cleared_at_ms = Some(cleared_at_ms);
|
||
current_level.elapsed_ms = Some(
|
||
cleared_at_ms
|
||
.saturating_sub(current_level.started_at_ms)
|
||
.max(1_000),
|
||
);
|
||
}
|
||
current_level.status = next_level_status;
|
||
}
|
||
|
||
if is_cleared
|
||
&& run.current_level.as_ref().map(|level| level.status)
|
||
!= Some(PuzzleRuntimeLevelStatus::Cleared)
|
||
{
|
||
next_run.cleared_level_count += 1;
|
||
}
|
||
next_run
|
||
}
|
||
|
||
fn current_unix_ms() -> u64 {
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|value| value.as_millis() as u64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn build_published_profile(
|
||
profile_id: &str,
|
||
owner_user_id: &str,
|
||
tags: Vec<&str>,
|
||
) -> PuzzleWorkProfile {
|
||
PuzzleWorkProfile {
|
||
work_id: format!("work-{profile_id}"),
|
||
profile_id: profile_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
source_session_id: None,
|
||
author_display_name: "作者".to_string(),
|
||
level_name: format!("{profile_id} 关"),
|
||
summary: "summary".to_string(),
|
||
theme_tags: tags.into_iter().map(|value| value.to_string()).collect(),
|
||
cover_image_src: Some("/cover.png".to_string()),
|
||
cover_asset_id: Some("asset-1".to_string()),
|
||
publication_status: PuzzlePublicationStatus::Published,
|
||
updated_at_micros: 100,
|
||
published_at_micros: Some(100),
|
||
play_count: 0,
|
||
remix_count: 0,
|
||
like_count: 0,
|
||
publish_ready: true,
|
||
anchor_pack: empty_anchor_pack(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_grid_size_matches_prd() {
|
||
assert_eq!(resolve_puzzle_grid_size(0), 3);
|
||
assert_eq!(resolve_puzzle_grid_size(2), 3);
|
||
assert_eq!(resolve_puzzle_grid_size(3), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_theme_tags_dedups_aliases() {
|
||
assert_eq!(
|
||
normalize_theme_tags(vec![
|
||
"蒸汽".to_string(),
|
||
"蒸汽朋克".to_string(),
|
||
"雨夜".to_string(),
|
||
"雨夜".to_string()
|
||
]),
|
||
vec!["蒸汽城市".to_string(), "雨夜".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_candidate_uses_oss_compatible_prefix_and_single_image() {
|
||
let anchor_pack = infer_anchor_pack("雨夜猫咪", Some("雨夜猫咪"));
|
||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||
let candidates = build_generated_candidates("session-1", None, &draft, 2, 1_000)
|
||
.expect("candidates should build");
|
||
|
||
assert_eq!(candidates.len(), 1);
|
||
assert!(
|
||
candidates[0]
|
||
.image_src
|
||
.starts_with("/generated-puzzle-assets/session-1/")
|
||
);
|
||
let legacy_public_prefix = ["generated-puzzle", "covers"].join("-");
|
||
assert!(!candidates[0].image_src.contains(&legacy_public_prefix));
|
||
}
|
||
|
||
#[test]
|
||
fn tag_similarity_score_uses_jaccard() {
|
||
let score = tag_similarity_score(
|
||
&["蒸汽城市".to_string(), "雨夜".to_string()],
|
||
&["蒸汽城市".to_string(), "猫咪".to_string()],
|
||
);
|
||
assert!((score - 0.3333).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn select_next_profile_prefers_same_tags_and_author() {
|
||
let current = build_published_profile("a", "owner-a", vec!["蒸汽城市", "雨夜"]);
|
||
let candidates = vec![
|
||
build_published_profile("b", "owner-a", vec!["蒸汽城市", "雨夜"]),
|
||
build_published_profile("c", "owner-c", vec!["猫咪", "森林"]),
|
||
];
|
||
let selected =
|
||
select_next_profile(¤t, &["a".to_string()], &candidates).expect("should select");
|
||
assert_eq!(selected.profile_id, "b");
|
||
}
|
||
|
||
#[test]
|
||
fn swap_pieces_marks_cleared_when_back_to_origin() {
|
||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||
let run = start_run("run-1".to_string(), &profile, 0).expect("run");
|
||
let current_level = run.current_level.clone().expect("level");
|
||
let first_piece = current_level.board.pieces[0].clone();
|
||
let second_piece = current_level.board.pieces[1].clone();
|
||
let swapped =
|
||
swap_pieces(&run, &first_piece.piece_id, &second_piece.piece_id).expect("swap");
|
||
assert_eq!(
|
||
swapped
|
||
.current_level
|
||
.as_ref()
|
||
.expect("level")
|
||
.board
|
||
.pieces
|
||
.len(),
|
||
9
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn initial_board_shuffle_changes_by_run_id() {
|
||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||
let first = start_run("run-random-a".to_string(), &profile, 0).expect("first run");
|
||
let second = start_run("run-random-b".to_string(), &profile, 0).expect("second run");
|
||
let first_positions = first
|
||
.current_level
|
||
.expect("first level")
|
||
.board
|
||
.pieces
|
||
.into_iter()
|
||
.map(|piece| (piece.current_row, piece.current_col))
|
||
.collect::<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 initial_board_has_no_original_neighbor_pairs() {
|
||
for grid_size in [3, 4] {
|
||
for shuffle_seed in 0..128 {
|
||
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
|
||
|
||
assert!(board.merged_groups.is_empty());
|
||
assert!(
|
||
!has_any_original_neighbor_pair(&board.pieces),
|
||
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn correct_neighbors_auto_merge_after_swap() {
|
||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||
let mut run =
|
||
start_run_with_shuffle_seed("run-merge".to_string(), &profile, 0, 7).expect("run");
|
||
let current_level = run.current_level.as_mut().expect("level");
|
||
current_level.board = rebuild_board_snapshot(
|
||
3,
|
||
vec![
|
||
PuzzlePieceState {
|
||
piece_id: "piece-0".to_string(),
|
||
correct_row: 0,
|
||
correct_col: 0,
|
||
current_row: 1,
|
||
current_col: 1,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-1".to_string(),
|
||
correct_row: 0,
|
||
correct_col: 1,
|
||
current_row: 0,
|
||
current_col: 1,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-2".to_string(),
|
||
correct_row: 0,
|
||
correct_col: 2,
|
||
current_row: 2,
|
||
current_col: 2,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-3".to_string(),
|
||
correct_row: 1,
|
||
correct_col: 0,
|
||
current_row: 0,
|
||
current_col: 2,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-4".to_string(),
|
||
correct_row: 1,
|
||
correct_col: 1,
|
||
current_row: 1,
|
||
current_col: 0,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-5".to_string(),
|
||
correct_row: 1,
|
||
correct_col: 2,
|
||
current_row: 2,
|
||
current_col: 0,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-6".to_string(),
|
||
correct_row: 2,
|
||
correct_col: 0,
|
||
current_row: 0,
|
||
current_col: 0,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-7".to_string(),
|
||
correct_row: 2,
|
||
correct_col: 1,
|
||
current_row: 1,
|
||
current_col: 2,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-8".to_string(),
|
||
correct_row: 2,
|
||
correct_col: 2,
|
||
current_row: 2,
|
||
current_col: 1,
|
||
merged_group_id: None,
|
||
},
|
||
],
|
||
None,
|
||
);
|
||
|
||
let swapped = swap_pieces(&run, "piece-0", "piece-6").expect("swap");
|
||
let board = &swapped.current_level.as_ref().expect("level").board;
|
||
let group = board
|
||
.merged_groups
|
||
.iter()
|
||
.find(|group| {
|
||
group.piece_ids.contains(&"piece-0".to_string())
|
||
&& group.piece_ids.contains(&"piece-1".to_string())
|
||
})
|
||
.expect("piece-0 and piece-1 should merge");
|
||
|
||
assert_eq!(group.piece_ids.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn single_piece_dragging_into_group_splits_target_group() {
|
||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||
let mut run =
|
||
start_run_with_shuffle_seed("run-split".to_string(), &profile, 0, 9).expect("run");
|
||
let current_level = run.current_level.as_mut().expect("level");
|
||
current_level.board = rebuild_board_snapshot(
|
||
3,
|
||
vec![
|
||
PuzzlePieceState {
|
||
piece_id: "piece-0".to_string(),
|
||
correct_row: 0,
|
||
correct_col: 0,
|
||
current_row: 0,
|
||
current_col: 0,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-1".to_string(),
|
||
correct_row: 0,
|
||
correct_col: 1,
|
||
current_row: 0,
|
||
current_col: 1,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-2".to_string(),
|
||
correct_row: 0,
|
||
correct_col: 2,
|
||
current_row: 2,
|
||
current_col: 2,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-3".to_string(),
|
||
correct_row: 1,
|
||
correct_col: 0,
|
||
current_row: 1,
|
||
current_col: 0,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-4".to_string(),
|
||
correct_row: 1,
|
||
correct_col: 1,
|
||
current_row: 1,
|
||
current_col: 1,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-5".to_string(),
|
||
correct_row: 1,
|
||
correct_col: 2,
|
||
current_row: 1,
|
||
current_col: 2,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-6".to_string(),
|
||
correct_row: 2,
|
||
correct_col: 0,
|
||
current_row: 2,
|
||
current_col: 0,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-7".to_string(),
|
||
correct_row: 2,
|
||
correct_col: 1,
|
||
current_row: 2,
|
||
current_col: 1,
|
||
merged_group_id: None,
|
||
},
|
||
PuzzlePieceState {
|
||
piece_id: "piece-8".to_string(),
|
||
correct_row: 2,
|
||
correct_col: 2,
|
||
current_row: 0,
|
||
current_col: 2,
|
||
merged_group_id: None,
|
||
},
|
||
],
|
||
None,
|
||
);
|
||
|
||
let dragged = drag_piece_or_group(&run, "piece-8", 0, 1).expect("drag");
|
||
let board = &dragged.current_level.as_ref().expect("level").board;
|
||
|
||
assert_eq!(
|
||
board
|
||
.pieces
|
||
.iter()
|
||
.find(|piece| piece.piece_id == "piece-8")
|
||
.map(|piece| (piece.current_row, piece.current_col)),
|
||
Some((0, 1))
|
||
);
|
||
assert!(
|
||
board
|
||
.merged_groups
|
||
.iter()
|
||
.all(|group| !(group.piece_ids.contains(&"piece-0".to_string())
|
||
&& group.piece_ids.contains(&"piece-1".to_string())))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn one_full_board_group_marks_level_cleared() {
|
||
let pieces = (0..9)
|
||
.map(|index| PuzzlePieceState {
|
||
piece_id: format!("piece-{index}"),
|
||
correct_row: index / 3,
|
||
correct_col: index % 3,
|
||
current_row: index / 3,
|
||
current_col: (index + 1) % 3,
|
||
merged_group_id: None,
|
||
})
|
||
.collect::<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 apply_publish_overrides_updates_draft_truth() {
|
||
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
|
||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||
|
||
let updated = apply_publish_overrides_to_draft(
|
||
&draft,
|
||
Some("雨夜猫塔".to_string()),
|
||
Some("一张更聚焦猫咪塔楼的夜景拼图。".to_string()),
|
||
Some(vec![
|
||
"雨夜".to_string(),
|
||
"猫咪".to_string(),
|
||
"遗迹".to_string(),
|
||
]),
|
||
)
|
||
.expect("publish overrides should succeed");
|
||
|
||
assert_eq!(updated.level_name, "雨夜猫塔");
|
||
assert_eq!(updated.summary, "一张更聚焦猫咪塔楼的夜景拼图。");
|
||
assert_eq!(
|
||
updated.theme_tags,
|
||
vec![
|
||
"猫咪".to_string(),
|
||
"神庙遗迹".to_string(),
|
||
"雨夜".to_string()
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_publish_overrides_rejects_invalid_tag_count() {
|
||
let anchor_pack = infer_anchor_pack("蒸汽城市", Some("蒸汽城市"));
|
||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||
let error =
|
||
apply_publish_overrides_to_draft(&draft, None, None, Some(vec!["蒸汽".to_string()]))
|
||
.expect_err("invalid tag count should fail");
|
||
|
||
assert_eq!(error, PuzzleFieldError::InvalidTagCount);
|
||
}
|
||
}
|