Files
Genarrative/server-rs/crates/module-puzzle/src/lib.rs
2026-04-29 11:51:04 +08:00

2439 lines
82 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;
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,
&current_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,
&current_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(&current_profile.theme_tags, &left.theme_tags)
.partial_cmp(&tag_similarity_score(
&current_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(&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 {
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(&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(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(&current, &["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);
}
}