Files
Genarrative/server-rs/crates/module-puzzle/src/application.rs
高物 a45e358e83 Add generationStatus and match3d/runtime fixes
Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
2026-05-16 22:59:02 +08:00

3746 lines
135 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.
//! 拼图应用编排与纯规则。
//!
//! 本层组合 Agent 草稿、作品发布、运行态棋盘和排行榜推荐规则;
//! 图片生成、OSS、HTTP response shape 与 SpacetimeDB 写回由外层 adapter 负责。
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::{Deserialize, Serialize};
use shared_kernel::{normalize_required_string, normalize_string_list};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{domain::*, errors::PuzzleFieldError};
#[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>,
}
pub fn empty_anchor_pack() -> PuzzleAnchorPack {
PuzzleAnchorPack {
theme_promise: PuzzleAnchorItem {
key: "themePromise".to_string(),
label: "题材承诺".to_string(),
value: String::new(),
status: PuzzleAnchorStatus::Missing,
},
visual_subject: PuzzleAnchorItem {
key: "visualSubject".to_string(),
label: "画面主体".to_string(),
value: String::new(),
status: PuzzleAnchorStatus::Missing,
},
visual_mood: PuzzleAnchorItem {
key: "visualMood".to_string(),
label: "视觉气质".to_string(),
value: String::new(),
status: PuzzleAnchorStatus::Missing,
},
composition_hooks: PuzzleAnchorItem {
key: "compositionHooks".to_string(),
label: "拼图记忆点".to_string(),
value: String::new(),
status: PuzzleAnchorStatus::Missing,
},
tags_and_forbidden: PuzzleAnchorItem {
key: "tagsAndForbidden".to_string(),
label: "标签与禁忌".to_string(),
value: String::new(),
status: PuzzleAnchorStatus::Missing,
},
}
}
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> PuzzleAnchorPack {
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
.or_else(|| normalize_required_string(seed_text));
let Some(source) = source else {
return empty_anchor_pack();
};
if let Some(form_seed) = parse_form_seed_text(&source) {
if form_seed.has_any_value() {
return build_form_anchor_pack(
form_seed.work_title.as_deref().unwrap_or(""),
form_seed.picture_description.as_deref().unwrap_or(""),
);
}
}
let mut pack = empty_anchor_pack();
pack.theme_promise.value = infer_theme_promise(&source);
pack.theme_promise.status = PuzzleAnchorStatus::Inferred;
pack.visual_subject.value = infer_visual_subject(&source);
pack.visual_subject.status = PuzzleAnchorStatus::Inferred;
pack.visual_mood.value = infer_visual_mood(&source);
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
pack.composition_hooks.value = infer_composition_hooks(&source);
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
pack.tags_and_forbidden.value = infer_tags_and_forbidden(&source);
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
pack
}
pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack {
let normalized_title = normalize_required_string(title);
let normalized_description = normalize_required_string(picture_description);
let mut pack = empty_anchor_pack();
if let Some(title) = normalized_title.as_ref() {
pack.theme_promise.value = title.clone();
pack.theme_promise.status = PuzzleAnchorStatus::Locked;
}
if let Some(description) = normalized_description.as_ref() {
pack.visual_subject.value = description.clone();
pack.visual_subject.status = PuzzleAnchorStatus::Locked;
}
pack.visual_mood.value = "清晰、适合拼图切块".to_string();
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(title, picture_description);
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
pack
}
pub fn build_creator_intent(
anchor_pack: &PuzzleAnchorPack,
messages: &[PuzzleAgentMessageSnapshot],
) -> PuzzleCreatorIntent {
PuzzleCreatorIntent {
source_mode: if is_form_anchor_pack(anchor_pack) {
"form".to_string()
} else {
"agent_chat".to_string()
},
raw_messages_summary: messages
.iter()
.rev()
.take(4)
.map(|entry| entry.text.clone())
.collect::<Vec<_>>()
.join(" / "),
theme_promise: anchor_pack.theme_promise.value.clone(),
visual_subject: anchor_pack.visual_subject.value.clone(),
visual_mood: split_phrase_list(&anchor_pack.visual_mood.value),
composition_hooks: split_phrase_list(&anchor_pack.composition_hooks.value),
theme_tags: split_phrase_list(&anchor_pack.tags_and_forbidden.value)
.into_iter()
.take(PUZZLE_MAX_TAG_COUNT)
.collect(),
forbidden_directives: vec![extract_forbidden_directive(
&anchor_pack.tags_and_forbidden.value,
)],
}
}
pub fn compile_result_draft(
anchor_pack: &PuzzleAnchorPack,
messages: &[PuzzleAgentMessageSnapshot],
) -> PuzzleResultDraft {
compile_result_draft_from_seed(anchor_pack, messages, None)
}
pub fn compile_result_draft_from_seed(
anchor_pack: &PuzzleAnchorPack,
messages: &[PuzzleAgentMessageSnapshot],
seed_text: Option<&str>,
) -> PuzzleResultDraft {
let creator_intent = build_creator_intent(anchor_pack, messages);
let normalized_tags = resolve_initial_theme_tags(seed_text, &creator_intent);
let work_description = resolve_work_description(seed_text, anchor_pack);
let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体");
let level_name =
build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1);
let work_title = resolve_work_title(seed_text, anchor_pack, &level_name);
let level = PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: level_name.clone(),
picture_description,
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
};
PuzzleResultDraft {
work_title,
work_description: work_description.clone(),
level_name,
summary: work_description,
theme_tags: normalized_tags,
forbidden_directives: creator_intent.forbidden_directives.clone(),
creator_intent: Some(creator_intent),
anchor_pack: anchor_pack.clone(),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
levels: vec![level],
form_draft: None,
}
}
pub fn build_form_draft_from_seed(
anchor_pack: &PuzzleAnchorPack,
seed_text: Option<&str>,
) -> PuzzleResultDraft {
let form_seed = seed_text.and_then(parse_form_seed_text);
build_form_draft_from_parts(
anchor_pack,
form_seed.as_ref().and_then(|seed| seed.work_title.clone()),
form_seed
.as_ref()
.and_then(|seed| seed.work_description.clone()),
form_seed.and_then(|seed| seed.picture_description),
)
}
pub fn build_form_draft_from_parts(
anchor_pack: &PuzzleAnchorPack,
work_title: Option<String>,
work_description: Option<String>,
picture_description: Option<String>,
) -> PuzzleResultDraft {
let work_title = work_title.and_then(|value| normalize_required_string(&value));
let work_description = work_description.and_then(|value| normalize_required_string(&value));
let picture_description =
picture_description.and_then(|value| normalize_required_string(&value));
let summary = work_description.clone().unwrap_or_default();
let level = PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: String::new(),
picture_description: picture_description.clone().unwrap_or_default(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
};
// 中文注释:这是生成前的表单草稿,只用于创作中心恢复和表单回填,不进入发布就绪判断。
PuzzleResultDraft {
work_title: work_title.clone().unwrap_or_default(),
work_description: summary.clone(),
level_name: String::new(),
summary,
theme_tags: Vec::new(),
forbidden_directives: Vec::new(),
creator_intent: None,
anchor_pack: anchor_pack.clone(),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
levels: vec![level],
form_draft: Some(PuzzleFormDraft {
work_title,
work_description,
picture_description,
}),
}
}
pub fn build_generated_candidates(
session_id: &str,
prompt_text: Option<&str>,
draft: &PuzzleResultDraft,
candidate_count: u32,
now_micros: i64,
) -> Result<Vec<PuzzleGeneratedImageCandidate>, PuzzleFieldError> {
let session_id =
normalize_required_string(session_id).ok_or(PuzzleFieldError::MissingSessionId)?;
let count = candidate_count.max(1).min(1);
let prompt = normalize_required_string(prompt_text.unwrap_or(&draft.summary))
.unwrap_or_else(|| draft.summary.clone());
Ok((0..count)
.map(|index| {
let candidate_seed = now_micros + i64::from(index);
let candidate_id = format!("{session_id}-candidate-{}", index + 1);
PuzzleGeneratedImageCandidate {
candidate_id: candidate_id.clone(),
// 拼图图片的正式持久化由 api-server 上传 OSS这里仅保留 reducer
// 单测/保底路径构造,前缀必须与 OSS 兼容路由一致,不能再指向 public 目录。
image_src: format!(
"/generated-puzzle-assets/{session_id}/{candidate_seed}/cover.svg"
),
asset_id: format!("puzzle-cover-{candidate_seed}"),
prompt: prompt.clone(),
actual_prompt: Some(prompt.clone()),
source_type: "generated".to_string(),
selected: index == 0,
}
})
.collect())
}
pub fn apply_selected_candidate(
mut draft: PuzzleResultDraft,
candidate_id: &str,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
let candidate_id =
normalize_required_string(candidate_id).ok_or(PuzzleFieldError::MissingText)?;
let mut selected_cover_image_src = None;
let mut selected_cover_asset_id = None;
let mut matched = false;
for candidate in &mut draft.candidates {
candidate.selected = candidate.candidate_id == candidate_id;
if candidate.selected {
matched = true;
selected_cover_image_src = Some(candidate.image_src.clone());
selected_cover_asset_id = Some(candidate.asset_id.clone());
}
}
if !matched {
return Err(PuzzleFieldError::InvalidOperation);
}
draft.selected_candidate_id = Some(candidate_id);
draft.cover_image_src = selected_cover_image_src;
draft.cover_asset_id = selected_cover_asset_id;
draft.generation_status = "ready".to_string();
Ok(draft)
}
pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft {
if draft.levels.is_empty() {
draft.levels = vec![PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: draft.level_name.clone(),
picture_description: fallback_text(
&draft.anchor_pack.visual_subject.value,
&draft.summary,
),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: draft.candidates.clone(),
selected_candidate_id: draft.selected_candidate_id.clone(),
cover_image_src: draft.cover_image_src.clone(),
cover_asset_id: draft.cover_asset_id.clone(),
generation_status: draft.generation_status.clone(),
}];
}
sync_primary_level_fields(&mut draft);
draft
}
pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) {
if let Some(primary_level) = draft.levels.first() {
draft.level_name = primary_level.level_name.clone();
draft.candidates = primary_level.candidates.clone();
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
draft.cover_image_src = primary_level.cover_image_src.clone();
draft.cover_asset_id = primary_level.cover_asset_id.clone();
draft.generation_status = primary_level.generation_status.clone();
}
draft.summary = draft.work_description.clone();
if draft.form_draft.is_some() {
draft.form_draft = Some(PuzzleFormDraft {
work_title: normalize_required_string(&draft.work_title),
work_description: normalize_required_string(&draft.work_description),
picture_description: draft
.levels
.first()
.and_then(|level| normalize_required_string(&level.picture_description)),
});
}
}
pub fn selected_puzzle_level(
draft: &PuzzleResultDraft,
level_id: Option<&str>,
) -> Option<PuzzleDraftLevel> {
let normalized = normalize_puzzle_draft(draft.clone());
let requested_level_id = level_id.and_then(normalize_required_string);
requested_level_id
.as_deref()
.and_then(|target_id| {
normalized
.levels
.iter()
.find(|level| level.level_id == target_id)
.cloned()
})
.or_else(|| normalized.levels.first().cloned())
}
pub fn replace_puzzle_level(
draft: &PuzzleResultDraft,
level: PuzzleDraftLevel,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
let mut next_draft = normalize_puzzle_draft(draft.clone());
let Some(index) = next_draft
.levels
.iter()
.position(|entry| entry.level_id == level.level_id)
else {
return Err(PuzzleFieldError::InvalidOperation);
};
next_draft.levels[index] = level;
sync_primary_level_fields(&mut next_draft);
Ok(next_draft)
}
pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft {
let mut next_draft = normalize_puzzle_draft(draft.clone());
let next_index = next_draft.levels.len() + 1;
let picture_description = next_draft
.levels
.first()
.map(|level| level.picture_description.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| fallback_text(&next_draft.anchor_pack.visual_subject.value, "画面主体"));
next_draft.levels.push(PuzzleDraftLevel {
level_id: format!("puzzle-level-{next_index}"),
level_name: build_level_name_from_picture(
picture_description.as_str(),
&next_draft.theme_tags,
next_index,
),
picture_description,
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
});
sync_primary_level_fields(&mut next_draft);
next_draft
}
pub fn remove_puzzle_level(
draft: &PuzzleResultDraft,
level_id: &str,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
let mut next_draft = normalize_puzzle_draft(draft.clone());
if next_draft.levels.len() <= 1 {
return Err(PuzzleFieldError::InvalidOperation);
}
let normalized_level_id =
normalize_required_string(level_id).ok_or(PuzzleFieldError::InvalidOperation)?;
next_draft
.levels
.retain(|level| level.level_id != normalized_level_id);
if next_draft.levels.is_empty() {
return Err(PuzzleFieldError::InvalidOperation);
}
sync_primary_level_fields(&mut next_draft);
Ok(next_draft)
}
pub fn build_result_preview(
draft: &PuzzleResultDraft,
author_display_name: Option<&str>,
) -> PuzzleResultPreviewEnvelope {
let normalized_draft = normalize_puzzle_draft(draft.clone());
if normalized_draft.form_draft.is_some() {
return PuzzleResultPreviewEnvelope {
draft: normalized_draft,
blockers: Vec::new(),
quality_findings: Vec::new(),
publish_ready: false,
};
}
let blockers = validate_publish_requirements(&normalized_draft, author_display_name);
PuzzleResultPreviewEnvelope {
draft: normalized_draft,
blockers,
quality_findings: Vec::new(),
publish_ready: validate_publish_requirements(draft, author_display_name).is_empty(),
}
}
pub fn validate_publish_requirements(
draft: &PuzzleResultDraft,
author_display_name: Option<&str>,
) -> Vec<PuzzleResultPreviewBlocker> {
let draft = normalize_puzzle_draft(draft.clone());
let mut blockers = Vec::new();
if normalize_required_string(&draft.work_title).is_none() {
blockers.push(PuzzleResultPreviewBlocker {
id: "missing-work-title".to_string(),
code: "MISSING_WORK_TITLE".to_string(),
message: "作品名称不能为空".to_string(),
});
}
if normalize_required_string(&draft.work_description).is_none() {
blockers.push(PuzzleResultPreviewBlocker {
id: "missing-work-description".to_string(),
code: "MISSING_WORK_DESCRIPTION".to_string(),
message: "作品描述不能为空".to_string(),
});
}
for level in &draft.levels {
if normalize_required_string(&level.level_name).is_none() {
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-level-name-{}", level.level_id),
code: "MISSING_LEVEL_NAME".to_string(),
message: "关卡名不能为空".to_string(),
});
}
if level
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-cover-image-{}", level.level_id),
code: "MISSING_COVER_IMAGE".to_string(),
message: "正式拼图图片尚未确定".to_string(),
});
}
}
if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|| draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT
{
blockers.push(PuzzleResultPreviewBlocker {
id: "invalid-tag-count".to_string(),
code: "INVALID_TAG_COUNT".to_string(),
message: "正式标签数量必须在 3 到 6 之间".to_string(),
});
}
if normalize_required_string(author_display_name.unwrap_or("")).is_none() {
blockers.push(PuzzleResultPreviewBlocker {
id: "missing-author".to_string(),
code: "MISSING_AUTHOR".to_string(),
message: "作者信息不可读".to_string(),
});
}
blockers
}
pub fn create_work_profile(
work_id: String,
profile_id: String,
owner_user_id: String,
source_session_id: Option<String>,
author_display_name: String,
draft: &PuzzleResultDraft,
updated_at_micros: i64,
) -> Result<PuzzleWorkProfile, PuzzleFieldError> {
let author_display_name = normalize_required_string(author_display_name)
.ok_or(PuzzleFieldError::MissingAuthorDisplayName)?;
let draft = normalize_puzzle_draft(draft.clone());
let preview = build_result_preview(&draft, Some(&author_display_name));
Ok(PuzzleWorkProfile {
work_id,
profile_id,
owner_user_id,
source_session_id,
author_display_name,
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
level_name: draft.level_name.clone(),
summary: draft.summary.clone(),
theme_tags: normalize_theme_tags(draft.theme_tags.clone()),
cover_image_src: draft.cover_image_src.clone(),
cover_asset_id: draft.cover_asset_id.clone(),
levels: draft.levels.clone(),
publication_status: PuzzlePublicationStatus::Draft,
updated_at_micros,
published_at_micros: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: preview.publish_ready,
anchor_pack: draft.anchor_pack.clone(),
})
}
pub fn publish_work_profile(
mut profile: PuzzleWorkProfile,
draft: &PuzzleResultDraft,
published_at_micros: i64,
) -> Result<PuzzleWorkProfile, PuzzleFieldError> {
let draft = normalize_puzzle_draft(draft.clone());
if !validate_publish_requirements(&draft, Some(&profile.author_display_name)).is_empty() {
return Err(PuzzleFieldError::InvalidOperation);
}
profile.work_title = draft.work_title.clone();
profile.work_description = draft.work_description.clone();
profile.level_name = draft.level_name.clone();
profile.summary = draft.summary.clone();
profile.theme_tags = normalize_theme_tags(draft.theme_tags.clone());
profile.cover_image_src = draft.cover_image_src.clone();
profile.cover_asset_id = draft.cover_asset_id.clone();
profile.levels = draft.levels.clone();
profile.publication_status = PuzzlePublicationStatus::Published;
profile.publish_ready = true;
profile.updated_at_micros = published_at_micros;
profile.published_at_micros = Some(published_at_micros);
Ok(profile)
}
/// 在发布前把结果页的轻量编辑字段覆盖回草稿真相。
/// 这里只允许覆盖 PRD 明确要求的关卡名、摘要与标签,不额外扩到更多结果页元数据。
pub fn apply_publish_overrides_to_draft(
draft: &PuzzleResultDraft,
work_title: Option<String>,
work_description: Option<String>,
level_name: Option<String>,
summary: Option<String>,
theme_tags: Option<Vec<String>>,
levels: Option<Vec<PuzzleDraftLevel>>,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
let mut next_draft = normalize_puzzle_draft(draft.clone());
if let Some(next_work_title) = work_title {
next_draft.work_title = normalize_required_string(&next_work_title).unwrap_or_default();
}
if let Some(next_work_description) = work_description {
next_draft.work_description =
normalize_required_string(&next_work_description).unwrap_or_default();
}
if let Some(next_level_name) = level_name {
if let Some(primary_level) = next_draft.levels.first_mut() {
primary_level.level_name =
normalize_required_string(&next_level_name).unwrap_or_default();
}
}
if let Some(next_summary) = summary
&& let Some(normalized_summary) = normalize_required_string(&next_summary)
{
next_draft.work_description = normalized_summary;
}
if let Some(next_theme_tags) = theme_tags {
let normalized_theme_tags = normalize_theme_tags(next_theme_tags);
if normalized_theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|| normalized_theme_tags.len() > PUZZLE_MAX_TAG_COUNT
{
return Err(PuzzleFieldError::InvalidTagCount);
}
next_draft.theme_tags = normalized_theme_tags;
}
if let Some(next_levels) = levels {
let normalized_levels = normalize_puzzle_levels(next_levels, &next_draft.theme_tags)?;
next_draft.levels = normalized_levels;
}
sync_primary_level_fields(&mut next_draft);
Ok(next_draft)
}
pub fn normalize_puzzle_levels(
levels: Vec<PuzzleDraftLevel>,
_theme_tags: &[String],
) -> Result<Vec<PuzzleDraftLevel>, PuzzleFieldError> {
let mut normalized_levels = Vec::new();
for (index, mut level) in levels.into_iter().enumerate() {
let level_id = normalize_required_string(&level.level_id)
.unwrap_or_else(|| format!("puzzle-level-{}", index + 1));
let picture_description = normalize_required_string(&level.picture_description)
.unwrap_or_else(|| format!("{}关画面", index + 1));
let picture_reference = level.picture_reference.and_then(normalize_required_string);
let level_name = normalize_required_string(&level.level_name).unwrap_or_default();
level.level_id = level_id;
level.level_name = level_name;
level.picture_description = picture_description;
level.picture_reference = picture_reference;
level.generation_status = normalize_required_string(&level.generation_status)
.unwrap_or_else(|| "idle".to_string());
normalized_levels.push(level);
}
if normalized_levels.is_empty() {
return Err(PuzzleFieldError::InvalidOperation);
}
Ok(normalized_levels)
}
pub fn is_supported_puzzle_grid_size(grid_size: u32) -> bool {
PUZZLE_SUPPORTED_GRID_SIZES.contains(&grid_size)
}
pub fn resolve_puzzle_level_config(level_index: u32) -> PuzzleLevelConfig {
let level_index = level_index.max(1);
match level_index {
1 => PuzzleLevelConfig {
grid_size: 3,
time_limit_ms: 300_000,
},
2 => PuzzleLevelConfig {
grid_size: 4,
time_limit_ms: 300_000,
},
3 => PuzzleLevelConfig {
grid_size: 5,
time_limit_ms: 300_000,
},
4 => PuzzleLevelConfig {
grid_size: 5,
time_limit_ms: 210_000,
},
_ => {
let loop_index = (level_index.saturating_sub(5) % 6) + 5;
match loop_index {
5 => PuzzleLevelConfig {
grid_size: 5,
time_limit_ms: 210_000,
},
6 => PuzzleLevelConfig {
grid_size: 6,
time_limit_ms: 240_000,
},
7 => PuzzleLevelConfig {
grid_size: 5,
time_limit_ms: 210_000,
},
8 => PuzzleLevelConfig {
grid_size: 7,
time_limit_ms: 270_000,
},
9 => PuzzleLevelConfig {
grid_size: 5,
time_limit_ms: 240_000,
},
_ => PuzzleLevelConfig {
grid_size: 7,
time_limit_ms: 270_000,
},
}
}
}
}
pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 {
resolve_puzzle_level_config(cleared_level_count + 1).grid_size
}
pub fn resolve_puzzle_level_time_limit_ms_by_index(level_index: u32) -> u64 {
resolve_puzzle_level_config(level_index.max(1)).time_limit_ms
}
pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 {
match grid_size {
3 | 4 | 5 => 300_000,
6 => 240_000,
7 => 270_000,
_ => 300_000,
}
}
fn first_profile_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel> {
normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone())
.into_iter()
.next()
}
fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel> {
normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone())
.into_iter()
.find(|level| {
level
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string)
.is_some()
|| level
.ui_background_image_object_key
.as_deref()
.and_then(normalize_required_string)
.is_some()
})
}
fn resolve_puzzle_runtime_ui_background_fields(
level: Option<&PuzzleDraftLevel>,
fallback_level: Option<&PuzzleDraftLevel>,
) -> (Option<String>, Option<String>) {
for candidate in [level, fallback_level].into_iter().flatten() {
let image_src = candidate
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string);
let object_key = candidate
.ui_background_image_object_key
.as_deref()
.and_then(|value| normalize_required_string(value.trim_start_matches('/')));
if image_src.is_some() || object_key.is_some() {
return (image_src, object_key);
}
}
(None, None)
}
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
let time_limit_ms = if level.time_limit_ms == 0 {
resolve_puzzle_level_time_limit_ms_by_index(level.level_index)
} else {
level.time_limit_ms
};
time_limit_ms.saturating_sub(resolve_effective_elapsed_ms(level, now_ms))
}
fn normalize_timer_fields(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) {
if level.started_at_ms == 0 {
level.started_at_ms = now_ms;
}
if level.time_limit_ms == 0 {
level.time_limit_ms = resolve_puzzle_level_time_limit_ms_by_index(level.level_index);
}
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing {
level.remaining_ms = level.time_limit_ms;
}
}
fn resolve_active_freeze_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
match (level.freeze_started_at_ms, level.freeze_until_ms) {
(Some(started_at), Some(until_ms)) => now_ms.min(until_ms).saturating_sub(started_at),
_ => 0,
}
}
fn resolve_effective_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
let pause_elapsed_ms = level
.pause_started_at_ms
.map(|started_at| now_ms.saturating_sub(started_at))
.unwrap_or(0);
now_ms
.saturating_sub(level.started_at_ms)
.saturating_sub(level.paused_accumulated_ms)
.saturating_sub(pause_elapsed_ms)
.saturating_sub(level.freeze_accumulated_ms)
.saturating_sub(resolve_active_freeze_elapsed_ms(level, now_ms))
}
fn settle_expired_freeze(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) {
let (Some(started_at), Some(until_ms)) = (level.freeze_started_at_ms, level.freeze_until_ms)
else {
return;
};
if now_ms < until_ms {
return;
}
level.freeze_accumulated_ms = level
.freeze_accumulated_ms
.saturating_add(until_ms.saturating_sub(started_at));
level.freeze_started_at_ms = None;
level.freeze_until_ms = None;
}
fn close_level_pause(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) {
if let Some(pause_started_at_ms) = level.pause_started_at_ms.take() {
level.paused_accumulated_ms = level
.paused_accumulated_ms
.saturating_add(now_ms.saturating_sub(pause_started_at_ms));
}
}
pub fn resolve_puzzle_run_timer_at(mut run: PuzzleRunSnapshot, now_ms: u64) -> PuzzleRunSnapshot {
let Some(current_level) = run.current_level.as_mut() else {
return run;
};
normalize_timer_fields(current_level, now_ms);
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
return run;
}
settle_expired_freeze(current_level, now_ms);
let effective_elapsed_ms = resolve_effective_elapsed_ms(current_level, now_ms);
current_level.remaining_ms = current_level
.time_limit_ms
.saturating_sub(effective_elapsed_ms);
if current_level.remaining_ms == 0 {
current_level.status = PuzzleRuntimeLevelStatus::Failed;
current_level.elapsed_ms = Some(current_level.time_limit_ms);
current_level.pause_started_at_ms = None;
current_level.freeze_started_at_ms = None;
current_level.freeze_until_ms = None;
}
run
}
pub fn resolve_puzzle_run_timer(run: PuzzleRunSnapshot) -> PuzzleRunSnapshot {
resolve_puzzle_run_timer_at(run, current_unix_ms())
}
pub fn set_puzzle_run_paused_at(
run: &PuzzleRunSnapshot,
paused: bool,
now_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
let current_level = next_run
.current_level
.as_mut()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
return Ok(next_run);
}
if paused {
if current_level.pause_started_at_ms.is_none() {
current_level.pause_started_at_ms = Some(now_ms);
}
return Ok(next_run);
}
close_level_pause(current_level, now_ms);
Ok(resolve_puzzle_run_timer_at(next_run, now_ms))
}
pub fn set_puzzle_run_paused(
run: &PuzzleRunSnapshot,
paused: bool,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
set_puzzle_run_paused_at(run, paused, current_unix_ms())
}
pub fn apply_puzzle_freeze_time_at(
run: &PuzzleRunSnapshot,
now_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
let current_level = next_run
.current_level
.as_mut()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
return Err(PuzzleFieldError::InvalidOperation);
}
close_level_pause(current_level, now_ms);
current_level.freeze_started_at_ms = Some(now_ms);
current_level.freeze_until_ms = Some(now_ms.saturating_add(PUZZLE_FREEZE_TIME_DURATION_MS));
Ok(next_run)
}
pub fn apply_puzzle_freeze_time(
run: &PuzzleRunSnapshot,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
apply_puzzle_freeze_time_at(run, current_unix_ms())
}
pub fn extend_failed_puzzle_time_at(
run: &PuzzleRunSnapshot,
now_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
let current_level = next_run
.current_level
.as_mut()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Failed {
return Err(PuzzleFieldError::InvalidOperation);
}
let total_consumed_before_extend = current_level
.time_limit_ms
.saturating_sub(PUZZLE_EXTEND_TIME_DURATION_MS);
current_level.status = PuzzleRuntimeLevelStatus::Playing;
current_level.elapsed_ms = None;
current_level.cleared_at_ms = None;
current_level.remaining_ms = PUZZLE_EXTEND_TIME_DURATION_MS;
current_level.started_at_ms = now_ms.saturating_sub(total_consumed_before_extend);
current_level.paused_accumulated_ms = 0;
current_level.pause_started_at_ms = None;
current_level.freeze_accumulated_ms = 0;
current_level.freeze_started_at_ms = None;
current_level.freeze_until_ms = None;
Ok(next_run)
}
pub fn extend_failed_puzzle_time(
run: &PuzzleRunSnapshot,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
extend_failed_puzzle_time_at(run, current_unix_ms())
}
pub fn build_initial_board(grid_size: u32) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
build_initial_board_with_seed(grid_size, 0)
}
pub fn build_initial_board_with_seed(
grid_size: u32,
shuffle_seed: u64,
) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
if !is_supported_puzzle_grid_size(grid_size) {
return Err(PuzzleFieldError::InvalidGridSize);
}
let pieces = build_initial_pieces_without_correct_neighbors(grid_size, shuffle_seed);
Ok(rebuild_board_snapshot(grid_size, pieces, None))
}
pub fn start_run(
run_id: String,
entry_profile: &PuzzleWorkProfile,
cleared_level_count: u32,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
start_run_at(
run_id,
entry_profile,
cleared_level_count,
current_unix_ms(),
)
}
pub fn start_run_at(
run_id: String,
entry_profile: &PuzzleWorkProfile,
cleared_level_count: u32,
started_at_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
let shuffle_seed = puzzle_shuffle_seed(
&run_id,
&entry_profile.profile_id,
cleared_level_count + 1,
grid_size,
);
start_run_with_shuffle_seed_at(
run_id,
entry_profile,
cleared_level_count,
shuffle_seed,
started_at_ms,
)
}
pub fn start_run_with_shuffle_seed(
run_id: String,
entry_profile: &PuzzleWorkProfile,
cleared_level_count: u32,
shuffle_seed: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
start_run_with_shuffle_seed_at(
run_id,
entry_profile,
cleared_level_count,
shuffle_seed,
current_unix_ms(),
)
}
pub fn start_run_with_shuffle_seed_at(
run_id: String,
entry_profile: &PuzzleWorkProfile,
cleared_level_count: u32,
shuffle_seed: u64,
started_at_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let level_index = cleared_level_count + 1;
let level_config = resolve_puzzle_level_config(level_index);
let grid_size = level_config.grid_size;
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let current_profile_level = first_profile_level(entry_profile);
let ui_background_level = first_profile_ui_background_level(entry_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
cleared_level_count,
current_level_index: level_index,
current_grid_size: grid_size,
played_profile_ids: vec![entry_profile.profile_id.clone()],
previous_level_tags: entry_profile.theme_tags.clone(),
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id,
level_index,
level_id: current_profile_level
.as_ref()
.map(|level| level.level_id.clone()),
grid_size,
profile_id: entry_profile.profile_id.clone(),
level_name: entry_profile.level_name.clone(),
author_display_name: entry_profile.author_display_name.clone(),
theme_tags: entry_profile.theme_tags.clone(),
cover_image_src: entry_profile.cover_image_src.clone(),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms: level_config.time_limit_ms,
remaining_ms: level_config.time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: default_puzzle_next_level_mode(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
})
}
pub fn swap_pieces(
run: &PuzzleRunSnapshot,
first_piece_id: &str,
second_piece_id: &str,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
swap_pieces_at(run, first_piece_id, second_piece_id, current_unix_ms())
}
pub fn swap_pieces_at(
run: &PuzzleRunSnapshot,
first_piece_id: &str,
second_piece_id: &str,
now_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let first_piece_id =
normalize_required_string(first_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
let second_piece_id =
normalize_required_string(second_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
let current_level = timed_run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
return Err(PuzzleFieldError::InvalidOperation);
}
let mut pieces = current_level.board.pieces.clone();
let first_index = pieces
.iter()
.position(|piece| piece.piece_id == first_piece_id)
.ok_or(PuzzleFieldError::MissingPieceId)?;
let second_index = pieces
.iter()
.position(|piece| piece.piece_id == second_piece_id)
.ok_or(PuzzleFieldError::MissingPieceId)?;
let (first_row, first_col) = (
pieces[first_index].current_row,
pieces[first_index].current_col,
);
let (second_row, second_col) = (
pieces[second_index].current_row,
pieces[second_index].current_col,
);
pieces[first_index].current_row = second_row;
pieces[first_index].current_col = second_col;
pieces[second_index].current_row = first_row;
pieces[second_index].current_col = first_col;
let affected_cells = [
PuzzleCellPosition {
row: first_row,
col: first_col,
},
PuzzleCellPosition {
row: second_row,
col: second_col,
},
];
let next_board = rebuild_board_snapshot_for_affected_cells(
current_level.grid_size,
&current_level.board,
pieces,
affected_cells,
None,
);
Ok(with_next_board_at(&timed_run, next_board, now_ms))
}
pub fn drag_piece_or_group(
run: &PuzzleRunSnapshot,
piece_id: &str,
target_row: u32,
target_col: u32,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
drag_piece_or_group_at(run, piece_id, target_row, target_col, current_unix_ms())
}
pub fn drag_piece_or_group_at(
run: &PuzzleRunSnapshot,
piece_id: &str,
target_row: u32,
target_col: u32,
now_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms);
let current_level = timed_run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
return Err(PuzzleFieldError::InvalidOperation);
}
let grid_size = current_level.grid_size;
if target_row >= grid_size || target_col >= grid_size {
return Err(PuzzleFieldError::InvalidTargetCell);
}
let mut pieces = current_level.board.pieces.clone();
let piece_index = pieces
.iter()
.position(|piece| piece.piece_id == piece_id)
.ok_or(PuzzleFieldError::MissingPieceId)?;
let source_group_id = pieces[piece_index].merged_group_id.clone();
let operation_cells = match source_group_id {
Some(group_id) => drag_group(&mut pieces, &group_id, target_row, target_col, grid_size)?,
None => drag_single_piece(&mut pieces, piece_index, target_row, target_col)?,
};
let next_board = rebuild_board_snapshot_for_affected_cells(
grid_size,
&current_level.board,
pieces,
operation_cells,
None,
);
Ok(with_next_board_at(&timed_run, next_board, now_ms))
}
pub fn rebuild_board_snapshot_for_affected_cells(
grid_size: u32,
previous_board: &PuzzleBoardSnapshot,
pieces: Vec<PuzzlePieceState>,
affected_cells: impl IntoIterator<Item = PuzzleCellPosition>,
selected_piece_id: Option<String>,
) -> PuzzleBoardSnapshot {
let affected_scope = expand_affected_cells(grid_size, affected_cells);
if affected_scope.is_empty() || previous_board.merged_groups.is_empty() {
return rebuild_board_snapshot(grid_size, pieces, selected_piece_id);
}
let mut recalculated_piece_ids = pieces
.iter()
.filter(|piece| affected_scope.contains(&(piece.current_row, piece.current_col)))
.map(|piece| piece.piece_id.clone())
.collect::<BTreeSet<_>>();
let previous_piece_by_id = previous_board
.pieces
.iter()
.map(|piece| (piece.piece_id.clone(), piece))
.collect::<BTreeMap<_, _>>();
for piece_id in recalculated_piece_ids.clone() {
if let Some(previous_piece) = previous_piece_by_id.get(&piece_id)
&& let Some(group_id) = previous_piece.merged_group_id.as_deref()
{
add_previous_group_piece_ids(previous_board, group_id, &mut recalculated_piece_ids);
}
}
let mut preserved_groups = Vec::new();
for group in &previous_board.merged_groups {
if group
.piece_ids
.iter()
.any(|piece_id| recalculated_piece_ids.contains(piece_id))
{
continue;
}
let occupied_cells = group
.piece_ids
.iter()
.filter_map(|piece_id| {
pieces
.iter()
.find(|piece| piece.piece_id == *piece_id)
.map(|piece| PuzzleCellPosition {
row: piece.current_row,
col: piece.current_col,
})
})
.collect::<Vec<_>>();
if occupied_cells.len() == group.piece_ids.len() {
preserved_groups.push(PuzzleMergedGroupState {
group_id: group.group_id.clone(),
piece_ids: group.piece_ids.clone(),
occupied_cells,
});
}
}
let recalculated_pieces = pieces
.iter()
.filter(|piece| recalculated_piece_ids.contains(&piece.piece_id))
.cloned()
.collect::<Vec<_>>();
let mut next_groups = preserved_groups;
next_groups.extend(resolve_merged_groups(&recalculated_pieces));
rebuild_board_snapshot_with_groups(grid_size, pieces, next_groups, selected_piece_id)
}
pub fn advance_next_level(
run: &PuzzleRunSnapshot,
next_profile: &PuzzleWorkProfile,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
advance_next_level_at(run, next_profile, current_unix_ms())
}
pub fn advance_next_level_at(
run: &PuzzleRunSnapshot,
next_profile: &PuzzleWorkProfile,
started_at_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let current_level = run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err(PuzzleFieldError::InvalidOperation);
}
let next_cleared_count = run.cleared_level_count;
let next_level_index = run.current_level_index + 1;
let next_level_config = resolve_puzzle_level_config(next_level_index);
let next_grid_size = next_level_config.grid_size;
let shuffle_seed = puzzle_shuffle_seed(
&run.run_id,
&next_profile.profile_id,
next_level_index,
next_grid_size,
);
let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?;
let mut played_profile_ids = run.played_profile_ids.clone();
played_profile_ids.push(next_profile.profile_id.clone());
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (mut ui_background_image_src, mut ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() {
ui_background_image_src = current_level.ui_background_image_src.clone();
ui_background_image_object_key = current_level.ui_background_image_object_key.clone();
}
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id.clone(),
cleared_level_count: next_cleared_count,
current_level_index: next_level_index,
current_grid_size: next_grid_size,
played_profile_ids,
previous_level_tags: next_profile.theme_tags.clone(),
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id: run.run_id.clone(),
level_index: next_level_index,
level_id: current_profile_level
.as_ref()
.map(|level| level.level_id.clone()),
grid_size: next_grid_size,
profile_id: next_profile.profile_id.clone(),
level_name: next_profile.level_name.clone(),
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms: next_level_config.time_limit_ms,
remaining_ms: next_level_config.time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: default_puzzle_next_level_mode(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
})
}
pub fn advance_to_new_work_first_level_at(
run: &PuzzleRunSnapshot,
next_profile: &PuzzleWorkProfile,
started_at_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let current_level = run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err(PuzzleFieldError::InvalidOperation);
}
// 中文注释:跨作品只切换到候选作品的第一张图,运行时关卡序号和难度循环继续累进。
let next_level_index = run.current_level_index + 1;
let level_config = resolve_puzzle_level_config(next_level_index);
let grid_size = level_config.grid_size;
let shuffle_seed = puzzle_shuffle_seed(
&run.run_id,
&next_profile.profile_id,
next_level_index,
grid_size,
);
let next_board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let mut played_profile_ids = run.played_profile_ids.clone();
if !played_profile_ids.contains(&next_profile.profile_id) {
played_profile_ids.push(next_profile.profile_id.clone());
}
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id.clone(),
cleared_level_count: run.cleared_level_count,
current_level_index: next_level_index,
current_grid_size: grid_size,
played_profile_ids,
previous_level_tags: next_profile.theme_tags.clone(),
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id: run.run_id.clone(),
level_index: next_level_index,
level_id: current_profile_level
.as_ref()
.map(|level| level.level_id.clone()),
grid_size,
profile_id: next_profile.profile_id.clone(),
level_name: next_profile.level_name.clone(),
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms: level_config.time_limit_ms,
remaining_ms: level_config.time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: default_puzzle_next_level_mode(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
})
}
pub fn selected_profile_level_after_index(
profile: &PuzzleWorkProfile,
current_level_index: u32,
) -> Option<PuzzleDraftLevel> {
if current_level_index == 0 {
return None;
}
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone());
normalized_levels.get(current_level_index as usize).cloned()
}
pub fn selected_profile_level_after_runtime_level(
profile: &PuzzleWorkProfile,
current_level: &PuzzleRuntimeLevelSnapshot,
) -> Option<PuzzleDraftLevel> {
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone());
if normalized_levels.len() <= 1 {
return None;
}
let matched_index = current_level
.level_id
.as_ref()
.and_then(|level_id| {
normalized_levels
.iter()
.position(|level| level.level_id == *level_id)
})
.or_else(|| {
current_level
.cover_image_src
.as_ref()
.and_then(|cover_image_src| {
normalized_levels.iter().position(|level| {
level.cover_image_src.as_ref() == Some(cover_image_src)
&& level.level_name == current_level.level_name
})
})
})
.or_else(|| {
normalized_levels.iter().position(|level| {
level.level_name == current_level.level_name
&& level.cover_image_src == current_level.cover_image_src
})
})
.or_else(|| {
current_level.level_index.checked_sub(1).and_then(|index| {
((index as usize) < normalized_levels.len()).then_some(index as usize)
})
})?;
normalized_levels.get(matched_index + 1).cloned()
}
pub fn selected_profile_level_index(profile: &PuzzleWorkProfile, level_id: &str) -> Option<usize> {
let target_level_id = normalize_required_string(level_id)?;
let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone());
normalized_levels
.iter()
.position(|level| level.level_id == target_level_id)
}
pub fn resolve_restart_cleared_level_count(profile: &PuzzleWorkProfile, level_id: &str) -> u32 {
// 中文注释:失败重开指定的是当前关 levelIdstart_run_at 用“已通关数 + 1”计算当前关所以这里返回关卡下标。
selected_profile_level_index(profile, level_id).unwrap_or(0) as u32
}
pub fn select_next_profile<'a>(
current_profile: &PuzzleWorkProfile,
played_profile_ids: &[String],
candidates: &'a [PuzzleWorkProfile],
) -> Option<&'a PuzzleWorkProfile> {
select_next_profiles(current_profile, played_profile_ids, candidates, 1)
.into_iter()
.next()
}
pub fn select_next_profiles<'a>(
current_profile: &PuzzleWorkProfile,
played_profile_ids: &[String],
candidates: &'a [PuzzleWorkProfile],
limit: usize,
) -> Vec<&'a PuzzleWorkProfile> {
if limit == 0 {
return Vec::new();
}
let mut available = candidates
.iter()
.filter(|candidate| {
candidate.publication_status == PuzzlePublicationStatus::Published
&& candidate.cover_image_src.is_some()
&& !candidate.theme_tags.is_empty()
&& candidate.profile_id != current_profile.profile_id
})
.collect::<Vec<_>>();
let has_unplayed = available
.iter()
.any(|candidate| !played_profile_ids.contains(&candidate.profile_id));
if has_unplayed {
available.retain(|candidate| !played_profile_ids.contains(&candidate.profile_id));
} else if let Some(last_played) = played_profile_ids.last() {
available.retain(|candidate| candidate.profile_id != *last_played);
}
available.sort_by(|left, right| {
let left_score = recommendation_score(current_profile, left);
let right_score = recommendation_score(current_profile, right);
right_score
.partial_cmp(&left_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
tag_similarity_score(&current_profile.theme_tags, &right.theme_tags)
.partial_cmp(&tag_similarity_score(
&current_profile.theme_tags,
&left.theme_tags,
))
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| left.play_count.cmp(&right.play_count))
.then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros))
});
available.truncate(limit);
available
}
pub fn recommendation_score(
current_profile: &PuzzleWorkProfile,
candidate: &PuzzleWorkProfile,
) -> f32 {
let tag_similarity = tag_similarity_score(&current_profile.theme_tags, &candidate.theme_tags);
let same_author_score = if current_profile.owner_user_id == candidate.owner_user_id {
1.0
} else {
0.0
};
tag_similarity * 0.7 + same_author_score * 0.3
}
pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32 {
let left_set = normalize_theme_tags(left_tags.to_vec())
.into_iter()
.collect::<BTreeSet<_>>();
let right_set = normalize_theme_tags(right_tags.to_vec())
.into_iter()
.collect::<BTreeSet<_>>();
if left_set.is_empty() && right_set.is_empty() {
return 0.0;
}
let intersection = left_set.intersection(&right_set).count() as f32;
let union = left_set.union(&right_set).count() as f32;
if union <= f32::EPSILON {
0.0
} else {
let lexical_score = intersection / union;
// 中文注释:优先复用 RPG build 标签的属性亲和度语义模型;拼图自有标签未命中时保留 Jaccard 兜底。
rpg_build_tag_set_similarity(&left_set, &right_set)
.map(|semantic_score| semantic_score.max(lexical_score))
.unwrap_or(lexical_score)
}
}
#[derive(Clone, Copy)]
struct RpgBuildTagSemanticDefinition {
category: &'static str,
affinity: [f32; 6],
}
fn rpg_affinity(strength: f32, agility: f32, intelligence: f32, spirit: f32) -> [f32; 6] {
[
strength * 0.72 + spirit * 0.28,
agility * 0.88 + intelligence * 0.12,
intelligence * 0.78 + agility * 0.22,
strength * 0.62 + agility * 0.18 + intelligence * 0.2,
spirit * 0.72 + intelligence * 0.28,
spirit * 0.74 + strength * 0.26,
]
}
fn resolve_rpg_build_tag_semantic(tag: &str) -> Option<RpgBuildTagSemanticDefinition> {
let normalized = tag.trim().to_lowercase();
let value = normalized.as_str();
let definition = match value {
"quickblade" | "快剑" | "快刀" | "决斗者" => {
("style", rpg_affinity(0.35, 1.0, 0.1, 0.05))
}
"combo" | "连段" | "连击" | "连锁" => ("style", rpg_affinity(0.3, 0.92, 0.18, 0.08)),
"dash" | "突进" | "冲锋" => ("style", rpg_affinity(0.45, 0.95, 0.0, 0.0)),
"pursuit" | "追击" => ("style", rpg_affinity(0.38, 0.88, 0.08, 0.02)),
"swiftstrike" | "快袭" | "刺袭" | "伏击" => {
("style", rpg_affinity(0.22, 0.98, 0.12, 0.04))
}
"ranged" | "远射" | "射击" | "箭矢" => {
("style", rpg_affinity(0.18, 0.82, 0.34, 0.08))
}
"guerrilla" | "游击" | "骚扰" => ("style", rpg_affinity(0.24, 0.9, 0.28, 0.12)),
"mobility" | "机动" | "敏捷" | "灵活" => {
("style", rpg_affinity(0.18, 1.0, 0.08, 0.08))
}
"windrun" | "风行" | "疾行" => ("style", rpg_affinity(0.08, 1.0, 0.1, 0.1)),
"heavyhit" | "重击" => ("style", rpg_affinity(1.0, 0.28, 0.02, 0.04)),
"burst" | "爆发" => ("style", rpg_affinity(0.72, 0.58, 0.36, 0.08)),
"armorbreak" | "破甲" => ("style", rpg_affinity(0.92, 0.28, 0.08, 0.02)),
"pressure" | "压制" => ("style", rpg_affinity(0.62, 0.64, 0.1, 0.08)),
"bloodrush" | "压血" => ("resource", rpg_affinity(0.84, 0.54, 0.04, 0.18)),
"guard" | "守御" | "守卫" | "防御" => {
("defense", rpg_affinity(0.7, 0.18, 0.04, 0.72))
}
"barrier" | "护体" | "护罩" | "护盾" => {
("defense", rpg_affinity(0.48, 0.08, 0.2, 0.92))
}
"heavyarmor" | "重甲" => ("defense", rpg_affinity(0.88, 0.04, 0.02, 0.54)),
"counter" | "反击" | "回击" => ("defense", rpg_affinity(0.66, 0.46, 0.14, 0.36)),
"banish" | "镇邪" => ("defense", rpg_affinity(0.24, 0.06, 0.54, 0.88)),
"caster" | "法修" | "法师" => ("element", rpg_affinity(0.0, 0.1, 1.0, 0.6)),
"mana" | "法力" => ("resource", rpg_affinity(0.02, 0.08, 0.94, 0.74)),
"thunder" | "雷法" => ("element", rpg_affinity(0.06, 0.24, 0.96, 0.42)),
"formation" | "符阵" | "法阵" => ("element", rpg_affinity(0.08, 0.12, 0.82, 0.96)),
"control" | "控场" | "控制" => ("style", rpg_affinity(0.12, 0.34, 0.78, 0.72)),
"overload" | "过载" => ("resource", rpg_affinity(0.14, 0.18, 0.92, 0.38)),
"heal" | "回复" | "治疗" => ("resource", rpg_affinity(0.02, 0.08, 0.56, 1.0)),
"support" | "护持" | "支援" | "祝福" => {
("resource", rpg_affinity(0.14, 0.14, 0.58, 0.98))
}
"sustain" | "续战" => ("resource", rpg_affinity(0.34, 0.18, 0.22, 0.9)),
"fate" | "命纹" => ("flow", rpg_affinity(0.08, 0.22, 0.72, 0.84)),
"fortune" | "机缘" => ("flow", rpg_affinity(0.06, 0.34, 0.7, 0.78)),
"cooldown" | "冷却" => ("resource", rpg_affinity(0.04, 0.46, 0.82, 0.4)),
"command" | "统御" => ("flow", rpg_affinity(0.38, 0.26, 0.72, 0.82)),
"balanced" | "均衡" | "平衡" | "全能" => {
("flow", rpg_affinity(0.58, 0.58, 0.58, 0.58))
}
"craft" | "工巧" | "工艺" => ("craft", rpg_affinity(0.24, 0.16, 0.74, 0.5)),
"alchemy" | "炼药" | "药剂" => ("craft", rpg_affinity(0.08, 0.16, 0.84, 0.76)),
"vanguard" | "先锋" => ("flow", rpg_affinity(0.82, 0.44, 0.08, 0.34)),
"berserk" | "狂战" => ("flow", rpg_affinity(0.98, 0.42, 0.0, 0.22)),
"spellblade" | "法剑" => ("flow", rpg_affinity(0.42, 0.42, 0.88, 0.38)),
"paladin" | "圣佑" | "圣骑士" => ("flow", rpg_affinity(0.58, 0.12, 0.42, 0.96)),
"fortress" | "堡垒" => ("flow", rpg_affinity(0.94, 0.04, 0.08, 0.82)),
"starter" | "起手" => ("flow", rpg_affinity(0.42, 0.42, 0.42, 0.42)),
_ => return None,
};
Some(RpgBuildTagSemanticDefinition {
category: definition.0,
affinity: definition.1,
})
}
fn normalized_affinity_dot(left: [f32; 6], right: [f32; 6]) -> f32 {
let left_magnitude = left.iter().map(|value| value * value).sum::<f32>().sqrt();
let right_magnitude = right.iter().map(|value| value * value).sum::<f32>().sqrt();
if left_magnitude <= 0.0001 || right_magnitude <= 0.0001 {
return 0.0;
}
left.iter()
.zip(right.iter())
.map(|(left_value, right_value)| {
(left_value / left_magnitude) * (right_value / right_magnitude)
})
.sum::<f32>()
}
fn rpg_build_tag_similarity(
left: RpgBuildTagSemanticDefinition,
right: RpgBuildTagSemanticDefinition,
) -> f32 {
let category_bonus = if left.category == right.category {
0.08
} else {
0.0
};
(normalized_affinity_dot(left.affinity, right.affinity) + category_bonus).min(1.0)
}
fn rpg_build_tag_directional_similarity(
left: &[RpgBuildTagSemanticDefinition],
right: &[RpgBuildTagSemanticDefinition],
) -> f32 {
if left.is_empty() || right.is_empty() {
return 0.0;
}
let total = left
.iter()
.map(|left_definition| {
right
.iter()
.map(|right_definition| {
rpg_build_tag_similarity(*left_definition, *right_definition)
})
.fold(0.0_f32, f32::max)
})
.sum::<f32>();
total / left.len() as f32
}
fn rpg_build_tag_set_similarity(
left_tags: &BTreeSet<String>,
right_tags: &BTreeSet<String>,
) -> Option<f32> {
let left_definitions = left_tags
.iter()
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
.collect::<Vec<_>>();
let right_definitions = right_tags
.iter()
.filter_map(|tag| resolve_rpg_build_tag_semantic(tag))
.collect::<Vec<_>>();
if left_definitions.is_empty() || right_definitions.is_empty() {
return None;
}
Some(
(rpg_build_tag_directional_similarity(&left_definitions, &right_definitions)
+ rpg_build_tag_directional_similarity(&right_definitions, &left_definitions))
/ 2.0,
)
}
pub fn normalize_theme_tags(tags: Vec<String>) -> Vec<String> {
let alias_map = BTreeMap::from([
("蒸汽", "蒸汽城市"),
("蒸汽朋克", "蒸汽城市"),
("遗迹", "神庙遗迹"),
("森林", "童话森林"),
("夜雨", "雨夜"),
("发光猫", "猫咪"),
]);
let mut normalized = normalize_string_list(tags)
.into_iter()
.flat_map(|value| split_phrase_list(&value))
.map(|value| {
alias_map
.get(value.as_str())
.map(|alias| (*alias).to_string())
.unwrap_or(value)
})
.collect::<Vec<_>>();
normalized.sort();
normalized.dedup();
normalized.into_iter().take(PUZZLE_MAX_TAG_COUNT).collect()
}
fn infer_theme_promise(source: &str) -> String {
if source.contains("神庙") {
"探索遗迹中的奇幻想象".to_string()
} else if source.contains("") {
"在雨夜中寻找视觉线索".to_string()
} else if source.contains("") {
"一眼记住可爱的猫咪奇景".to_string()
} else {
"用一张强识别度画面承诺幻想题材".to_string()
}
}
fn infer_visual_subject(source: &str) -> String {
if source.contains("") {
"发光猫咪".to_string()
} else if source.contains("神庙") {
"巨大遗迹入口".to_string()
} else if source.contains("城市") {
"蒸汽城市核心地标".to_string()
} else {
"画面中央的核心主体".to_string()
}
}
fn infer_visual_mood(source: &str) -> String {
if source.contains("悬疑") {
"悬疑、静谧".to_string()
} else if source.contains("温暖") {
"温暖、柔和".to_string()
} else if source.contains("机械") {
"机械、奇诡".to_string()
} else {
"梦幻、清晰".to_string()
}
}
fn infer_composition_hooks(source: &str) -> String {
if source.contains("") {
"高塔轮廓、纵向构图、亮色焦点".to_string()
} else if source.contains("遗迹") {
"入口轮廓、对称台阶、地标雕像".to_string()
} else {
"主体轮廓、色块分区、地标元素".to_string()
}
}
fn infer_tags_and_forbidden(source: &str) -> String {
if source.contains("神庙") {
"神庙遗迹、童话森林、雨夜;禁止标题字".to_string()
} else if source.contains("") {
"猫咪、童话森林、发光;禁止水印".to_string()
} else {
"蒸汽城市、雨夜、奇幻;禁止按钮".to_string()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct PuzzleFormSeedParts {
work_title: Option<String>,
work_description: Option<String>,
picture_description: Option<String>,
}
impl PuzzleFormSeedParts {
fn has_any_value(&self) -> bool {
self.work_title.is_some()
|| self.work_description.is_some()
|| self.picture_description.is_some()
}
}
fn parse_form_seed_text(source: &str) -> Option<PuzzleFormSeedParts> {
let normalized_source = source.trim();
if normalized_source.is_empty() {
return None;
}
let title_marker = if normalized_source.contains("作品名称:") {
"作品名称:"
} else {
"拼图标题:"
};
let parts = PuzzleFormSeedParts {
work_title: extract_form_seed_value(normalized_source, title_marker),
work_description: extract_form_seed_value(normalized_source, "作品描述:"),
picture_description: extract_form_seed_value(normalized_source, "画面描述:"),
};
parts.has_any_value().then_some(parts)
}
fn extract_form_seed_value(source: &str, marker: &str) -> Option<String> {
let value_start = source.find(marker)? + marker.len();
let value_end = ["作品名称:", "拼图标题:", "作品描述:", "画面描述:"]
.into_iter()
.filter(|next_marker| *next_marker != marker)
.filter_map(|next_marker| {
source[value_start..]
.find(next_marker)
.map(|index| value_start + index)
})
.min()
.unwrap_or(source.len());
normalize_required_string(&source[value_start..value_end])
}
fn build_form_tags_and_forbidden(title: &str, picture_description: &str) -> String {
let mut tags = derive_form_theme_tags(title, picture_description);
if tags.len() < PUZZLE_MIN_TAG_COUNT {
for fallback in ["拼图", "插画", "清晰构图"] {
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
if tags.len() >= PUZZLE_MIN_TAG_COUNT {
break;
}
}
}
format!("{};禁止标题字", tags.join(""))
}
fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec<String> {
let source = format!("{title} {picture_description}");
let keyword_tags = [
("", "猫咪"),
("", "小狗"),
("神庙", "神庙遗迹"),
("遗迹", "神庙遗迹"),
("森林", "童话森林"),
("", "雨夜"),
("", "夜景"),
("城市", "城市奇景"),
("蒸汽", "蒸汽城市"),
("机械", "机械幻想"),
("", "海岸"),
("", "花园"),
("", "雪景"),
("", "幻想生物"),
("", "暖灯"),
];
let mut tags = keyword_tags
.into_iter()
.filter(|(keyword, _)| source.contains(keyword))
.map(|(_, tag)| tag.to_string())
.collect::<Vec<_>>();
for value in title
.split(|ch: char| ch.is_whitespace() || matches!(ch, '' | '、' | ',' | '' | ';'))
.filter_map(normalize_required_string)
{
if value.chars().count() <= 8 {
tags.push(value);
}
}
normalize_theme_tags(tags)
}
fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool {
matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked)
|| matches!(
anchor_pack.visual_subject.status,
PuzzleAnchorStatus::Locked
)
}
fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String {
if is_form_anchor_pack(anchor_pack) {
return fallback_text(&anchor_pack.visual_subject.value, "画面主体");
}
format!(
"{},主体是{},氛围偏{}",
fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"),
fallback_text(&anchor_pack.visual_subject.value, "画面主体"),
fallback_text(&anchor_pack.visual_mood.value, "温暖")
)
}
fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String {
if let Some(parts) = seed_text.and_then(parse_form_seed_text) {
if parts.picture_description.is_some()
&& parts.work_title.is_none()
&& parts.work_description.is_none()
{
return String::new();
}
return parts
.work_description
.unwrap_or_else(|| build_result_summary(anchor_pack));
}
build_result_summary(anchor_pack)
}
fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String {
fallback_text(&anchor_pack.theme_promise.value, "奇景拼图")
}
fn resolve_work_title(
seed_text: Option<&str>,
anchor_pack: &PuzzleAnchorPack,
level_name: &str,
) -> String {
seed_text
.and_then(parse_form_seed_text)
.and_then(|parts| {
parts
.work_title
.or_else(|| normalize_required_string(level_name))
})
.unwrap_or_else(|| build_work_title(anchor_pack))
}
fn resolve_initial_theme_tags(
seed_text: Option<&str>,
creator_intent: &PuzzleCreatorIntent,
) -> Vec<String> {
if let Some(parts) = seed_text.and_then(parse_form_seed_text) {
if parts.picture_description.is_some()
&& parts.work_title.is_none()
&& parts.work_description.is_none()
{
return Vec::new();
}
let derived_tags = normalize_theme_tags(derive_form_theme_tags(
parts
.work_title
.as_deref()
.unwrap_or(creator_intent.theme_promise.as_str()),
parts
.picture_description
.as_deref()
.unwrap_or(creator_intent.visual_subject.as_str()),
));
if !derived_tags.is_empty() {
return derived_tags;
}
}
normalize_theme_tags(creator_intent.theme_tags.clone())
}
fn extract_forbidden_directive(source: &str) -> String {
if let Some((_, tail)) = source.split_once('') {
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
}
"禁止标题字".to_string()
}
fn build_level_name_from_picture(
picture_description: &str,
normalized_tags: &[String],
level_index: usize,
) -> String {
let source = normalize_required_string(picture_description).unwrap_or_default();
for keyword in [
"", "", "神庙", "遗迹", "森林", "雨夜", "城市", "机械", "", "", "", "", "",
"",
] {
if source.contains(keyword) {
return format!("{keyword}画面");
}
}
if let Some(tag) = normalized_tags.first() {
return format!("{tag}画面");
}
format!("{level_index}")
}
fn fallback_text(value: &str, fallback: &str) -> String {
normalize_required_string(value).unwrap_or_else(|| fallback.to_string())
}
fn split_phrase_list(value: &str) -> Vec<String> {
value
.replace('', ",")
.replace('、', ",")
.replace('', ",")
.split(',')
.filter_map(normalize_required_string)
.collect()
}
fn puzzle_shuffle_seed(run_id: &str, profile_id: &str, level_index: u32, grid_size: u32) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in run_id
.bytes()
.chain(profile_id.bytes())
.chain(level_index.to_le_bytes())
.chain(grid_size.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn shuffle_positions(positions: &mut [PuzzleCellPosition], seed: u64) {
if positions.len() <= 1 {
return;
}
let mut state = seed ^ ((positions.len() as u64) << 32) ^ 0x9e37_79b9_7f4a_7c15;
for index in (1..positions.len()).rev() {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let swap_index = (state % ((index + 1) as u64)) as usize;
positions.swap(index, swap_index);
}
}
fn build_initial_pieces_without_correct_neighbors(
grid_size: u32,
shuffle_seed: u64,
) -> Vec<PuzzlePieceState> {
let base_positions = build_correct_positions(grid_size);
for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS {
let mut positions = base_positions.clone();
shuffle_positions(
&mut positions,
shuffle_seed.wrapping_add(attempt.wrapping_mul(0x9e37_79b9_7f4a_7c15)),
);
ensure_board_is_not_solved(&mut positions, grid_size);
let pieces = build_pieces_from_positions(grid_size, &positions);
if !has_any_original_neighbor_pair(&pieces) {
return pieces;
}
}
// 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。
let fallback_pieces = build_deterministic_neighbor_free_pieces(grid_size, shuffle_seed)
.or_else(|| build_original_neighbor_free_pieces(grid_size, shuffle_seed))
.unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions));
debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces));
fallback_pieces
}
fn build_correct_positions(grid_size: u32) -> Vec<PuzzleCellPosition> {
let total = grid_size * grid_size;
(0..total)
.map(|index| PuzzleCellPosition {
row: index / grid_size,
col: index % grid_size,
})
.collect()
}
fn build_pieces_from_positions(
grid_size: u32,
positions: &[PuzzleCellPosition],
) -> Vec<PuzzlePieceState> {
positions
.iter()
.enumerate()
.map(|(index, current)| {
let index = index as u32;
PuzzlePieceState {
piece_id: format!("piece-{index}"),
correct_row: index / grid_size,
correct_col: index % grid_size,
current_row: current.row,
current_col: current.col,
merged_group_id: None,
}
})
.collect()
}
fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u32) {
if positions.len() <= 1 {
return;
}
let is_solved = positions.iter().enumerate().all(|(index, position)| {
position.row == index as u32 / grid_size && position.col == index as u32 % grid_size
});
if is_solved {
positions.rotate_left(1);
}
}
fn has_any_original_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
let pieces_by_cell = pieces
.iter()
.map(|piece| ((piece.current_row, piece.current_col), piece))
.collect::<BTreeMap<_, _>>();
pieces.iter().any(|piece| {
neighbor_cells(piece.current_row, piece.current_col)
.into_iter()
.filter_map(|cell| pieces_by_cell.get(&cell))
.any(|neighbor| are_original_neighbors(piece, neighbor))
})
}
fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool {
left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1
}
fn build_deterministic_neighbor_free_pieces(
grid_size: u32,
shuffle_seed: u64,
) -> Option<Vec<PuzzlePieceState>> {
// 中文注释:大棋盘随机命中“无原图相邻贴边”的概率较低,失败后用确定性排列兜底保证稳定开局。
let positions = match grid_size {
3 => build_seeded_3x3_neighbor_free_positions(shuffle_seed),
4 | 6 => build_affine_neighbor_free_positions(grid_size, 1, 1, 2, 1, shuffle_seed),
5 | 7 => {
build_affine_neighbor_free_positions(grid_size, 0, 1, 2, grid_size - 1, shuffle_seed)
}
_ => return None,
};
let pieces = build_pieces_from_positions(grid_size, &positions);
(!has_any_original_neighbor_pair(&pieces)).then_some(pieces)
}
fn build_seeded_3x3_neighbor_free_positions(shuffle_seed: u64) -> Vec<PuzzleCellPosition> {
const LAYOUTS: [[(u32, u32); 9]; 6] = [
[
(0, 1),
(1, 0),
(1, 2),
(2, 0),
(0, 2),
(2, 1),
(1, 1),
(2, 2),
(0, 0),
],
[
(0, 1),
(1, 0),
(1, 2),
(2, 0),
(0, 2),
(2, 1),
(2, 2),
(1, 1),
(0, 0),
],
[
(0, 1),
(1, 0),
(1, 2),
(2, 0),
(2, 2),
(0, 0),
(1, 1),
(0, 2),
(2, 1),
],
[
(0, 1),
(1, 0),
(1, 2),
(2, 1),
(0, 2),
(2, 0),
(0, 0),
(2, 2),
(1, 1),
],
[
(0, 1),
(1, 0),
(1, 2),
(2, 2),
(0, 2),
(2, 1),
(1, 1),
(2, 0),
(0, 0),
],
[
(0, 1),
(1, 0),
(2, 1),
(2, 0),
(2, 2),
(0, 2),
(1, 2),
(0, 0),
(1, 1),
],
];
let layout = &LAYOUTS[(shuffle_seed as usize) % LAYOUTS.len()];
layout
.into_iter()
.map(|(row, col)| PuzzleCellPosition {
row: *row,
col: *col,
})
.collect()
}
fn build_affine_neighbor_free_positions(
grid_size: u32,
row_from_row: u32,
row_from_col: u32,
col_from_row: u32,
col_from_col: u32,
shuffle_seed: u64,
) -> Vec<PuzzleCellPosition> {
let row_offset = (shuffle_seed % u64::from(grid_size)) as u32;
let col_offset = ((shuffle_seed / u64::from(grid_size)) % u64::from(grid_size)) as u32;
(0..(grid_size * grid_size))
.map(|index| {
let row = index / grid_size;
let col = index % grid_size;
PuzzleCellPosition {
row: (row_from_row * row + row_from_col * col + row_offset) % grid_size,
col: (col_from_row * row + col_from_col * col + col_offset) % grid_size,
}
})
.collect()
}
fn build_original_neighbor_free_pieces(
grid_size: u32,
shuffle_seed: u64,
) -> Option<Vec<PuzzlePieceState>> {
let total = (grid_size * grid_size) as usize;
let mut piece_order = (0..total as u32).collect::<Vec<_>>();
sort_indices_by_seed(&mut piece_order, shuffle_seed ^ 0xa076_1d64_78bd_642f);
let mut cell_order = build_correct_positions(grid_size);
sort_cells_by_seed(&mut cell_order, shuffle_seed ^ 0xe703_7ed1_a0b4_28db);
let mut placements = vec![None; total];
let mut used_cells = BTreeSet::new();
if place_neighbor_free_piece(
grid_size,
&piece_order,
&cell_order,
0,
&mut placements,
&mut used_cells,
) {
Some(
placements
.into_iter()
.enumerate()
.filter_map(|(index, current)| {
current.map(|current| PuzzlePieceState {
piece_id: format!("piece-{index}"),
correct_row: index as u32 / grid_size,
correct_col: index as u32 % grid_size,
current_row: current.row,
current_col: current.col,
merged_group_id: None,
})
})
.collect(),
)
} else {
None
}
}
fn place_neighbor_free_piece(
grid_size: u32,
piece_order: &[u32],
cell_order: &[PuzzleCellPosition],
depth: usize,
placements: &mut [Option<PuzzleCellPosition>],
used_cells: &mut BTreeSet<(u32, u32)>,
) -> bool {
let Some(piece_index) = piece_order.get(depth).copied() else {
return true;
};
for cell in cell_order {
if used_cells.contains(&(cell.row, cell.col)) {
continue;
}
if cell.row == piece_index / grid_size && cell.col == piece_index % grid_size {
continue;
}
if violates_original_neighbor_free_rule(grid_size, piece_index, cell.clone(), placements) {
continue;
}
placements[piece_index as usize] = Some(cell.clone());
used_cells.insert((cell.row, cell.col));
if place_neighbor_free_piece(
grid_size,
piece_order,
cell_order,
depth + 1,
placements,
used_cells,
) {
return true;
}
used_cells.remove(&(cell.row, cell.col));
placements[piece_index as usize] = None;
}
false
}
fn violates_original_neighbor_free_rule(
grid_size: u32,
piece_index: u32,
cell: PuzzleCellPosition,
placements: &[Option<PuzzleCellPosition>],
) -> bool {
placements
.iter()
.enumerate()
.filter_map(|(placed_index, placed_cell)| {
placed_cell
.as_ref()
.map(|placed_cell| (placed_index as u32, placed_cell))
})
.any(|(placed_index, placed_cell)| {
let original_neighbors = (piece_index / grid_size).abs_diff(placed_index / grid_size)
+ (piece_index % grid_size).abs_diff(placed_index % grid_size)
== 1;
let current_neighbors =
cell.row.abs_diff(placed_cell.row) + cell.col.abs_diff(placed_cell.col) == 1;
original_neighbors && current_neighbors
})
}
fn sort_indices_by_seed(indices: &mut [u32], seed: u64) {
indices.sort_by_key(|index| seeded_order_key(seed, u64::from(*index)));
}
fn sort_cells_by_seed(cells: &mut [PuzzleCellPosition], seed: u64) {
cells.sort_by_key(|cell| seeded_order_key(seed, u64::from(cell.row * 16 + cell.col)));
}
fn seeded_order_key(seed: u64, value: u64) -> u64 {
let mut state = seed ^ value.wrapping_mul(0x9e37_79b9_7f4a_7c15);
state ^= state >> 30;
state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9);
state ^= state >> 27;
state = state.wrapping_mul(0x94d0_49bb_1331_11eb);
state ^ (state >> 31)
}
fn rebuild_board_snapshot(
grid_size: u32,
pieces: Vec<PuzzlePieceState>,
selected_piece_id: Option<String>,
) -> PuzzleBoardSnapshot {
let merged_groups = resolve_merged_groups(&pieces);
rebuild_board_snapshot_with_groups(grid_size, pieces, merged_groups, selected_piece_id)
}
fn rebuild_board_snapshot_with_groups(
grid_size: u32,
mut pieces: Vec<PuzzlePieceState>,
merged_groups: Vec<PuzzleMergedGroupState>,
selected_piece_id: Option<String>,
) -> PuzzleBoardSnapshot {
let merged_groups = normalize_group_ids(merged_groups);
let group_by_piece = merged_groups
.iter()
.flat_map(|group| {
group
.piece_ids
.iter()
.cloned()
.map(|piece_id| (piece_id, group.group_id.clone()))
})
.collect::<BTreeMap<_, _>>();
for piece in &mut pieces {
piece.merged_group_id = group_by_piece.get(&piece.piece_id).cloned();
}
let all_pieces_in_correct_cells = pieces.iter().all(|piece| {
piece.correct_row == piece.current_row && piece.correct_col == piece.current_col
});
let all_pieces_merged_into_one_group = merged_groups
.iter()
.any(|group| group.piece_ids.len() == pieces.len() && pieces.len() > 1);
let all_tiles_resolved = all_pieces_in_correct_cells || all_pieces_merged_into_one_group;
PuzzleBoardSnapshot {
rows: grid_size,
cols: grid_size,
pieces,
merged_groups,
selected_piece_id,
all_tiles_resolved,
}
}
fn normalize_group_ids(groups: Vec<PuzzleMergedGroupState>) -> Vec<PuzzleMergedGroupState> {
groups
.into_iter()
.enumerate()
.map(|(index, group)| PuzzleMergedGroupState {
group_id: format!("group-{}", index + 1),
..group
})
.collect()
}
fn expand_affected_cells(
grid_size: u32,
cells: impl IntoIterator<Item = PuzzleCellPosition>,
) -> BTreeSet<(u32, u32)> {
let mut scope = BTreeSet::new();
for cell in cells {
if cell.row >= grid_size || cell.col >= grid_size {
continue;
}
scope.insert((cell.row, cell.col));
for (row, col) in neighbor_cells(cell.row, cell.col) {
if row < grid_size && col < grid_size {
scope.insert((row, col));
}
}
}
scope
}
fn add_previous_group_piece_ids(
previous_board: &PuzzleBoardSnapshot,
group_id: &str,
piece_ids: &mut BTreeSet<String>,
) {
if let Some(group) = previous_board
.merged_groups
.iter()
.find(|group| group.group_id == group_id)
{
piece_ids.extend(group.piece_ids.iter().cloned());
}
}
fn resolve_merged_groups(pieces: &[PuzzlePieceState]) -> Vec<PuzzleMergedGroupState> {
let pieces_by_cell = pieces
.iter()
.map(|piece| ((piece.current_row, piece.current_col), piece))
.collect::<BTreeMap<_, _>>();
let pieces_by_id = pieces
.iter()
.map(|piece| (piece.piece_id.clone(), piece))
.collect::<BTreeMap<_, _>>();
let mut visited = BTreeSet::new();
let mut groups = Vec::new();
for piece in pieces {
if visited.contains(&piece.piece_id) {
continue;
}
let mut queue = VecDeque::from([piece.piece_id.clone()]);
let mut collected_ids = Vec::new();
while let Some(current_piece_id) = queue.pop_front() {
if !visited.insert(current_piece_id.clone()) {
continue;
}
let current_piece = match pieces_by_id.get(&current_piece_id) {
Some(value) => *value,
None => continue,
};
collected_ids.push(current_piece_id.clone());
for (neighbor_row, neighbor_col) in
neighbor_cells(current_piece.current_row, current_piece.current_col)
{
if let Some(neighbor_piece) = pieces_by_cell.get(&(neighbor_row, neighbor_col))
&& are_correct_neighbors(current_piece, neighbor_piece)
{
queue.push_back(neighbor_piece.piece_id.clone());
}
}
}
if collected_ids.len() <= 1 {
continue;
}
let occupied_cells = collected_ids
.iter()
.filter_map(|piece_id| pieces_by_id.get(piece_id).copied())
.map(|piece| PuzzleCellPosition {
row: piece.current_row,
col: piece.current_col,
})
.collect::<Vec<_>>();
groups.push(PuzzleMergedGroupState {
group_id: format!("group-{}", groups.len() + 1),
piece_ids: collected_ids,
occupied_cells,
});
}
groups
}
fn neighbor_cells(row: u32, col: u32) -> Vec<(u32, u32)> {
let mut neighbors = Vec::new();
if row > 0 {
neighbors.push((row - 1, col));
}
neighbors.push((row + 1, col));
if col > 0 {
neighbors.push((row, col - 1));
}
neighbors.push((row, col + 1));
neighbors
}
fn are_correct_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool {
let current_row_delta = right.current_row as i32 - left.current_row as i32;
let current_col_delta = right.current_col as i32 - left.current_col as i32;
let correct_row_delta = right.correct_row as i32 - left.correct_row as i32;
let correct_col_delta = right.correct_col as i32 - left.correct_col as i32;
(current_row_delta.abs() + current_col_delta.abs()) == 1
&& current_row_delta == correct_row_delta
&& current_col_delta == correct_col_delta
}
fn drag_single_piece(
pieces: &mut [PuzzlePieceState],
piece_index: usize,
target_row: u32,
target_col: u32,
) -> Result<Vec<PuzzleCellPosition>, PuzzleFieldError> {
let target_index = pieces
.iter()
.position(|piece| piece.current_row == target_row && piece.current_col == target_col)
.ok_or(PuzzleFieldError::InvalidTargetCell)?;
let mut affected_cells = vec![
PuzzleCellPosition {
row: pieces[piece_index].current_row,
col: pieces[piece_index].current_col,
},
PuzzleCellPosition {
row: target_row,
col: target_col,
},
];
if let Some(target_group_id) = pieces[target_index].merged_group_id.clone() {
for piece in pieces
.iter_mut()
.filter(|piece| piece.merged_group_id.as_deref() == Some(target_group_id.as_str()))
{
affected_cells.push(PuzzleCellPosition {
row: piece.current_row,
col: piece.current_col,
});
piece.merged_group_id = None;
}
}
let (source_row, source_col) = (
pieces[piece_index].current_row,
pieces[piece_index].current_col,
);
pieces[piece_index].current_row = target_row;
pieces[piece_index].current_col = target_col;
if target_index != piece_index {
pieces[target_index].current_row = source_row;
pieces[target_index].current_col = source_col;
}
Ok(affected_cells)
}
fn drag_group(
pieces: &mut [PuzzlePieceState],
group_id: &str,
target_row: u32,
target_col: u32,
grid_size: u32,
) -> Result<Vec<PuzzleCellPosition>, PuzzleFieldError> {
let group_indices = pieces
.iter()
.enumerate()
.filter_map(|(index, piece)| {
(piece.merged_group_id.as_deref() == Some(group_id)).then_some(index)
})
.collect::<Vec<_>>();
if group_indices.is_empty() {
return Err(PuzzleFieldError::InvalidOperation);
}
let anchor_piece = &pieces[group_indices[0]];
let row_offset = target_row as i32 - anchor_piece.current_row as i32;
let col_offset = target_col as i32 - anchor_piece.current_col as i32;
let mut target_positions = Vec::new();
for &index in &group_indices {
let next_row = pieces[index].current_row as i32 + row_offset;
let next_col = pieces[index].current_col as i32 + col_offset;
if next_row < 0
|| next_col < 0
|| next_row >= grid_size as i32
|| next_col >= grid_size as i32
{
return Err(PuzzleFieldError::InvalidTargetCell);
}
target_positions.push((index, next_row as u32, next_col as u32));
}
let moving_piece_ids = group_indices
.iter()
.map(|index| pieces[*index].piece_id.clone())
.collect::<BTreeSet<_>>();
let source_positions = group_indices
.iter()
.map(|index| (pieces[*index].current_row, pieces[*index].current_col))
.collect::<Vec<_>>();
let mut affected_cells = source_positions
.iter()
.map(|(row, col)| PuzzleCellPosition {
row: *row,
col: *col,
})
.collect::<Vec<_>>();
for (index, next_row, next_col) in &target_positions {
affected_cells.push(PuzzleCellPosition {
row: *next_row,
col: *next_col,
});
if let Some(target_piece_index) = pieces.iter().position(|piece| {
piece.current_row == *next_row
&& piece.current_col == *next_col
&& !moving_piece_ids.contains(&piece.piece_id)
}) {
let fallback = source_positions
.iter()
.find(|position| {
!target_positions
.iter()
.any(|(_, row, col)| row == &position.0 && col == &position.1)
})
.copied()
.ok_or(PuzzleFieldError::InvalidOperation)?;
pieces[target_piece_index].merged_group_id = None;
affected_cells.push(PuzzleCellPosition {
row: pieces[target_piece_index].current_row,
col: pieces[target_piece_index].current_col,
});
affected_cells.push(PuzzleCellPosition {
row: fallback.0,
col: fallback.1,
});
pieces[target_piece_index].current_row = fallback.0;
pieces[target_piece_index].current_col = fallback.1;
}
pieces[*index].current_row = *next_row;
pieces[*index].current_col = *next_col;
}
Ok(affected_cells)
}
fn with_next_board_at(
run: &PuzzleRunSnapshot,
next_board: PuzzleBoardSnapshot,
now_ms: u64,
) -> PuzzleRunSnapshot {
let mut next_run = run.clone();
let is_cleared = next_board.all_tiles_resolved;
let next_level_status = if is_cleared {
PuzzleRuntimeLevelStatus::Cleared
} else {
PuzzleRuntimeLevelStatus::Playing
};
if let Some(current_level) = next_run.current_level.as_mut() {
current_level.board = next_board;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
current_level.cleared_at_ms = Some(now_ms);
current_level.elapsed_ms =
Some(resolve_effective_elapsed_ms(current_level, now_ms).max(1_000));
current_level.remaining_ms = 0;
}
current_level.status = next_level_status;
}
if is_cleared
&& run.current_level.as_ref().map(|level| level.status)
!= Some(PuzzleRuntimeLevelStatus::Cleared)
{
next_run.cleared_level_count += 1;
}
next_run
}
fn current_unix_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis() as u64)
.unwrap_or(0)
}
pub fn current_puzzle_unix_micros() -> i64 {
(current_unix_ms() as i64).saturating_mul(1_000)
}
pub fn puzzle_point_incentive_claimable_points(total_half_points: u64, claimed_points: u64) -> u64 {
total_half_points
.saturating_div(2)
.saturating_sub(claimed_points)
}
pub fn puzzle_point_incentive_total_after_spend(total_half_points: u64, spent_points: u64) -> u64 {
total_half_points.saturating_add(spent_points)
}
#[cfg(test)]
mod tests {
use super::*;
fn build_published_profile(
profile_id: &str,
owner_user_id: &str,
tags: Vec<&str>,
) -> PuzzleWorkProfile {
PuzzleWorkProfile {
work_id: format!("work-{profile_id}"),
profile_id: profile_id.to_string(),
owner_user_id: owner_user_id.to_string(),
source_session_id: None,
author_display_name: "作者".to_string(),
work_title: format!("{profile_id} 作品"),
work_description: "summary".to_string(),
level_name: format!("{profile_id} 关"),
summary: "summary".to_string(),
theme_tags: tags.into_iter().map(|value| value.to_string()).collect(),
cover_image_src: Some("/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
levels: vec![PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: format!("{profile_id} 关"),
picture_description: "summary".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
}],
publication_status: PuzzlePublicationStatus::Published,
updated_at_micros: 100,
published_at_micros: Some(100),
play_count: 0,
recent_play_count_7d: 0,
remix_count: 0,
like_count: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: true,
anchor_pack: empty_anchor_pack(),
}
}
#[test]
fn resolve_grid_size_matches_prd() {
assert_eq!(resolve_puzzle_grid_size(0), 3);
assert_eq!(resolve_puzzle_grid_size(1), 4);
assert_eq!(resolve_puzzle_grid_size(2), 5);
assert_eq!(resolve_puzzle_grid_size(3), 5);
assert_eq!(resolve_puzzle_grid_size(4), 5);
assert_eq!(resolve_puzzle_grid_size(5), 6);
assert_eq!(resolve_puzzle_grid_size(6), 5);
assert_eq!(resolve_puzzle_grid_size(7), 7);
assert_eq!(resolve_puzzle_grid_size(8), 5);
assert_eq!(resolve_puzzle_grid_size(9), 7);
assert_eq!(resolve_puzzle_grid_size(10), 5);
assert_eq!(resolve_puzzle_grid_size(15), 7);
}
#[test]
fn resolve_level_time_limit_matches_prd() {
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(1), 300_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(2), 300_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(3), 300_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(4), 210_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(5), 210_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(6), 240_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(7), 210_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(8), 270_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(9), 240_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(10), 270_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(11), 210_000);
assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(16), 270_000);
}
#[test]
fn form_draft_preserves_partial_initial_fields() {
let seed_text = "作品名称:月台拼图\n作品描述:";
let anchor_pack = infer_anchor_pack(seed_text, Some(seed_text));
let draft = build_form_draft_from_seed(&anchor_pack, Some(seed_text));
let form_draft = draft.form_draft.expect("form draft should exist");
assert_eq!(form_draft.work_title.as_deref(), Some("月台拼图"));
assert_eq!(form_draft.work_description, None);
assert_eq!(form_draft.picture_description, None);
assert_eq!(draft.work_title, "月台拼图");
assert_eq!(draft.work_description, "");
assert_eq!(draft.level_name, "");
assert_eq!(draft.levels[0].level_name, "");
assert_eq!(draft.anchor_pack.theme_promise.value, "月台拼图");
assert_eq!(draft.anchor_pack.visual_subject.value, "");
}
#[test]
fn normalize_theme_tags_dedups_aliases() {
assert_eq!(
normalize_theme_tags(vec![
"蒸汽".to_string(),
"蒸汽朋克".to_string(),
"雨夜".to_string(),
"雨夜".to_string()
]),
vec!["蒸汽城市".to_string(), "雨夜".to_string()]
);
}
#[test]
fn generated_candidate_uses_oss_compatible_prefix_and_single_image() {
let anchor_pack = infer_anchor_pack("雨夜猫咪", Some("雨夜猫咪"));
let draft = compile_result_draft(&anchor_pack, &[]);
let candidates = build_generated_candidates("session-1", None, &draft, 2, 1_000)
.expect("candidates should build");
assert_eq!(candidates.len(), 1);
assert!(
candidates[0]
.image_src
.starts_with("/generated-puzzle-assets/session-1/")
);
let legacy_public_prefix = ["generated-puzzle", "covers"].join("-");
assert!(!candidates[0].image_src.contains(&legacy_public_prefix));
}
#[test]
fn form_seed_locks_title_and_picture_description_as_primary_anchors() {
let anchor_pack = infer_anchor_pack(
"作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。",
None,
);
let draft = compile_result_draft_from_seed(
&anchor_pack,
&[],
Some(
"作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。",
),
);
assert_eq!(anchor_pack.theme_promise.value, "暖灯猫街");
assert_eq!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked);
assert_eq!(anchor_pack.visual_subject.value, "一只猫在雨夜灯牌下回头。");
assert_eq!(
anchor_pack.visual_subject.status,
PuzzleAnchorStatus::Locked
);
assert_eq!(draft.work_title, "暖灯猫街");
assert_eq!(draft.work_description, "一套雨夜猫街主题拼图。");
assert_eq!(draft.summary, "一套雨夜猫街主题拼图。");
assert_eq!(draft.level_name, "猫画面");
assert_eq!(
draft.levels[0].picture_description,
"一只猫在雨夜灯牌下回头。"
);
assert_eq!(
draft
.creator_intent
.as_ref()
.map(|intent| intent.source_mode.as_str()),
Some("form")
);
assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT);
}
#[test]
fn picture_only_form_seed_uses_level_name_as_work_title_and_empty_metadata() {
let seed_text = "画面描述:一只猫在雨夜灯牌下回头。";
let anchor_pack = infer_anchor_pack(seed_text, None);
let draft = compile_result_draft_from_seed(&anchor_pack, &[], Some(seed_text));
assert_eq!(draft.level_name, "猫画面");
assert_eq!(draft.work_title, "猫画面");
assert_eq!(draft.work_description, "");
assert_eq!(draft.summary, "");
assert!(draft.theme_tags.is_empty());
assert_eq!(
draft.levels[0].picture_description,
"一只猫在雨夜灯牌下回头。"
);
}
#[test]
fn form_seed_keeps_multiline_picture_description() {
let anchor_pack = infer_anchor_pack(
"拼图标题:雨夜猫街\n画面描述:一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。",
None,
);
let draft = compile_result_draft(&anchor_pack, &[]);
assert_eq!(
anchor_pack.visual_subject.value,
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
);
assert_eq!(
draft.levels[0].picture_description,
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
);
assert_eq!(draft.summary, draft.work_description);
assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪"));
assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜"));
}
#[test]
fn tag_similarity_score_uses_jaccard_fallback() {
let score = tag_similarity_score(
&["蒸汽城市".to_string(), "雨夜".to_string()],
&["蒸汽城市".to_string(), "猫咪".to_string()],
);
assert!((score - 0.3333).abs() < 0.01);
}
#[test]
fn tag_similarity_score_prefers_rpg_build_semantic_affinity() {
let score = tag_similarity_score(&["快剑".to_string()], &["连击".to_string()]);
assert!(score > 0.75);
}
#[test]
fn select_next_profile_prefers_same_tags_and_author() {
let current = build_published_profile("a", "owner-a", vec!["蒸汽城市", "雨夜"]);
let candidates = vec![
build_published_profile("b", "owner-a", vec!["蒸汽城市", "雨夜"]),
build_published_profile("c", "owner-c", vec!["猫咪", "森林"]),
];
let selected =
select_next_profile(&current, &["a".to_string()], &candidates).expect("should select");
assert_eq!(selected.profile_id, "b");
}
#[test]
fn restart_cleared_count_uses_selected_level_index() {
let mut profile = build_published_profile("entry", "owner-a", vec!["机关"]);
profile.levels = vec![
PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: "第一关".to_string(),
picture_description: "第一关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-1.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
},
PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
},
];
assert_eq!(
resolve_restart_cleared_level_count(&profile, "puzzle-level-2"),
1
);
assert_eq!(
resolve_restart_cleared_level_count(&profile, "missing-level"),
0
);
}
#[test]
fn advance_to_new_work_first_profile_level_keeps_runtime_progress() {
let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻", "遗迹"]);
let next_profile = build_published_profile("next", "owner-b", vec!["奇幻", "魔法"]);
let mut run = start_run("run-cross-work".to_string(), &first_profile, 2).expect("run");
run.cleared_level_count = run.current_level_index;
let current_level = run.current_level.as_mut().expect("level");
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_run =
advance_to_new_work_first_level_at(&run, &next_profile, 3_000).expect("next run");
assert_eq!(next_run.entry_profile_id, "entry");
assert_eq!(next_run.cleared_level_count, 3);
assert_eq!(next_run.current_level_index, 4);
let next_level = next_run.current_level.expect("next level");
assert_eq!(next_level.profile_id, "next");
assert_eq!(next_level.level_index, 4);
assert_eq!(next_level.grid_size, 5);
assert_eq!(next_level.time_limit_ms, 210_000);
}
#[test]
fn start_run_carries_first_level_background_music() {
let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
profile.levels[0].background_music = Some(PuzzleAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("puzzle_background_music".to_string()),
audio_src: "/generated-puzzle-assets/background.mp3".to_string(),
prompt: Some(String::new()),
title: Some("奇境初见".to_string()),
updated_at: Some("2026-05-12T00:00:00Z".to_string()),
});
profile.levels[0].ui_background_image_object_key =
Some("generated-puzzle-assets/background-ui.png".to_string());
let run = start_run("run-music".to_string(), &profile, 0).expect("run");
let current_level = run.current_level.as_ref().expect("level");
assert_eq!(
current_level
.background_music
.as_ref()
.map(|music| music.audio_src.as_str()),
Some("/generated-puzzle-assets/background.mp3".to_string()).as_deref()
);
assert_eq!(
current_level.ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/background-ui.png")
);
}
#[test]
fn advance_to_new_work_first_level_carries_ui_background_object_key() {
let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
let mut next_profile = build_published_profile("next", "owner-b", vec!["奇幻"]);
next_profile.levels[0].ui_background_image_object_key =
Some("generated-puzzle-assets/next-ui.png".to_string());
let run = start_run("run-ui".to_string(), &first_profile, 2).expect("run");
let mut cleared_run = run.clone();
cleared_run.cleared_level_count = cleared_run.current_level_index;
let current_level = cleared_run.current_level.as_mut().expect("level");
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_run = advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000)
.expect("next run");
assert_eq!(
next_run
.current_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.as_deref()),
Some("generated-puzzle-assets/next-ui.png")
);
}
#[test]
fn same_work_next_level_inherits_first_available_ui_background() {
let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
profile.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/entry-ui.png".to_string());
profile.levels.push(PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
});
let mut run = start_run("run-same-work-ui".to_string(), &profile, 0).expect("run");
run.cleared_level_count = run.current_level_index;
let current_level = run.current_level.as_mut().expect("level");
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_level = selected_profile_level_after_runtime_level(&profile, current_level)
.expect("same work next level");
let mut next_profile = profile.clone();
next_profile.level_name = next_level.level_name.clone();
next_profile.cover_image_src = next_level.cover_image_src.clone();
next_profile.cover_asset_id = next_level.cover_asset_id.clone();
next_profile.levels = vec![next_level];
let next_run = advance_next_level_at(&run, &next_profile, 3_000).expect("next run");
assert_eq!(
next_run
.current_level
.as_ref()
.and_then(|level| level.ui_background_image_src.as_deref()),
Some("/generated-puzzle-assets/entry-ui.png")
);
}
#[test]
fn swap_pieces_marks_cleared_when_back_to_origin() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let run = start_run("run-1".to_string(), &profile, 0).expect("run");
let current_level = run.current_level.clone().expect("level");
let first_piece = current_level.board.pieces[0].clone();
let second_piece = current_level.board.pieces[1].clone();
let swapped =
swap_pieces(&run, &first_piece.piece_id, &second_piece.piece_id).expect("swap");
assert_eq!(
swapped
.current_level
.as_ref()
.expect("level")
.board
.pieces
.len(),
9
);
}
#[test]
fn initial_board_shuffle_changes_by_run_id() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let first = start_run("run-random-a".to_string(), &profile, 0).expect("first run");
let second = start_run("run-random-b".to_string(), &profile, 0).expect("second run");
let first_positions = first
.current_level
.expect("first level")
.board
.pieces
.into_iter()
.map(|piece| (piece.current_row, piece.current_col))
.collect::<Vec<_>>();
let second_positions = second
.current_level
.expect("second level")
.board
.pieces
.into_iter()
.map(|piece| (piece.current_row, piece.current_col))
.collect::<Vec<_>>();
assert_ne!(first_positions, second_positions);
}
#[test]
fn puzzle_point_incentive_uses_half_points_and_floor_claimable() {
// 中文注释:累计单位是 half point消耗 1 个泥点只让作者获得 0.5 个待结算泥点。
assert_eq!(puzzle_point_incentive_total_after_spend(0, 1), 1);
assert_eq!(puzzle_point_incentive_claimable_points(1, 0), 0);
assert_eq!(puzzle_point_incentive_claimable_points(2, 0), 1);
assert_eq!(puzzle_point_incentive_claimable_points(5, 1), 1);
assert_eq!(puzzle_point_incentive_claimable_points(5, 2), 0);
}
#[test]
fn initial_board_has_no_original_neighbor_pairs() {
for grid_size in PUZZLE_SUPPORTED_GRID_SIZES {
for shuffle_seed in 0..128 {
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
assert!(board.merged_groups.is_empty());
assert!(
!has_any_original_neighbor_pair(&board.pieces),
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
);
}
}
}
#[test]
fn correct_neighbors_auto_merge_after_swap() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let mut run =
start_run_with_shuffle_seed("run-merge".to_string(), &profile, 0, 7).expect("run");
let current_level = run.current_level.as_mut().expect("level");
current_level.board = rebuild_board_snapshot(
3,
vec![
PuzzlePieceState {
piece_id: "piece-0".to_string(),
correct_row: 0,
correct_col: 0,
current_row: 1,
current_col: 1,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-1".to_string(),
correct_row: 0,
correct_col: 1,
current_row: 0,
current_col: 1,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-2".to_string(),
correct_row: 0,
correct_col: 2,
current_row: 2,
current_col: 2,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-3".to_string(),
correct_row: 1,
correct_col: 0,
current_row: 0,
current_col: 2,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-4".to_string(),
correct_row: 1,
correct_col: 1,
current_row: 1,
current_col: 0,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-5".to_string(),
correct_row: 1,
correct_col: 2,
current_row: 2,
current_col: 0,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-6".to_string(),
correct_row: 2,
correct_col: 0,
current_row: 0,
current_col: 0,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-7".to_string(),
correct_row: 2,
correct_col: 1,
current_row: 1,
current_col: 2,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-8".to_string(),
correct_row: 2,
correct_col: 2,
current_row: 2,
current_col: 1,
merged_group_id: None,
},
],
None,
);
let swapped = swap_pieces(&run, "piece-0", "piece-6").expect("swap");
let board = &swapped.current_level.as_ref().expect("level").board;
let group = board
.merged_groups
.iter()
.find(|group| {
group.piece_ids.contains(&"piece-0".to_string())
&& group.piece_ids.contains(&"piece-1".to_string())
})
.expect("piece-0 and piece-1 should merge");
assert_eq!(group.piece_ids.len(), 2);
}
#[test]
fn single_piece_dragging_into_group_splits_target_group() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let mut run =
start_run_with_shuffle_seed("run-split".to_string(), &profile, 0, 9).expect("run");
let current_level = run.current_level.as_mut().expect("level");
current_level.board = rebuild_board_snapshot(
3,
vec![
PuzzlePieceState {
piece_id: "piece-0".to_string(),
correct_row: 0,
correct_col: 0,
current_row: 0,
current_col: 0,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-1".to_string(),
correct_row: 0,
correct_col: 1,
current_row: 0,
current_col: 1,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-2".to_string(),
correct_row: 0,
correct_col: 2,
current_row: 2,
current_col: 2,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-3".to_string(),
correct_row: 1,
correct_col: 0,
current_row: 1,
current_col: 0,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-4".to_string(),
correct_row: 1,
correct_col: 1,
current_row: 1,
current_col: 1,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-5".to_string(),
correct_row: 1,
correct_col: 2,
current_row: 1,
current_col: 2,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-6".to_string(),
correct_row: 2,
correct_col: 0,
current_row: 2,
current_col: 0,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-7".to_string(),
correct_row: 2,
correct_col: 1,
current_row: 2,
current_col: 1,
merged_group_id: None,
},
PuzzlePieceState {
piece_id: "piece-8".to_string(),
correct_row: 2,
correct_col: 2,
current_row: 0,
current_col: 2,
merged_group_id: None,
},
],
None,
);
let dragged = drag_piece_or_group(&run, "piece-8", 0, 1).expect("drag");
let board = &dragged.current_level.as_ref().expect("level").board;
assert_eq!(
board
.pieces
.iter()
.find(|piece| piece.piece_id == "piece-8")
.map(|piece| (piece.current_row, piece.current_col)),
Some((0, 1))
);
assert!(
board
.merged_groups
.iter()
.all(|group| !(group.piece_ids.contains(&"piece-0".to_string())
&& group.piece_ids.contains(&"piece-1".to_string())))
);
}
#[test]
fn one_full_board_group_marks_level_cleared() {
let pieces = (0..9)
.map(|index| PuzzlePieceState {
piece_id: format!("piece-{index}"),
correct_row: index / 3,
correct_col: index % 3,
current_row: index / 3,
current_col: (index + 1) % 3,
merged_group_id: None,
})
.collect::<Vec<_>>();
let board = rebuild_board_snapshot_with_groups(
3,
pieces,
vec![PuzzleMergedGroupState {
group_id: "group-full".to_string(),
piece_ids: (0..9).map(|index| format!("piece-{index}")).collect(),
occupied_cells: (0..9)
.map(|index| PuzzleCellPosition {
row: index / 3,
col: (index + 1) % 3,
})
.collect(),
}],
None,
);
assert!(board.all_tiles_resolved);
}
#[test]
fn timer_marks_running_level_failed_after_limit() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let mut run =
start_run_with_shuffle_seed("run-timeout".to_string(), &profile, 0, 11).expect("run");
let level = run.current_level.as_mut().expect("level");
level.started_at_ms = current_unix_ms().saturating_sub(level.time_limit_ms + 1_000);
let timed_run = resolve_puzzle_run_timer(run);
let timed_level = timed_run.current_level.as_ref().expect("level");
assert_eq!(timed_level.status, PuzzleRuntimeLevelStatus::Failed);
assert_eq!(timed_level.remaining_ms, 0);
assert_eq!(timed_level.elapsed_ms, Some(timed_level.time_limit_ms));
}
#[test]
fn failed_level_can_extend_one_minute() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let now_ms = current_unix_ms();
let mut run =
start_run_with_shuffle_seed("run-extend".to_string(), &profile, 0, 14).expect("run");
let level = run.current_level.as_mut().expect("level");
level.started_at_ms = now_ms.saturating_sub(level.time_limit_ms + 1_000);
let failed_run = resolve_puzzle_run_timer_at(run, now_ms);
let extended_run = extend_failed_puzzle_time_at(&failed_run, now_ms + 5_000)
.expect("extend should succeed");
let extended_level = extended_run.current_level.as_ref().expect("level");
assert_eq!(extended_level.status, PuzzleRuntimeLevelStatus::Playing);
assert_eq!(extended_level.remaining_ms, PUZZLE_EXTEND_TIME_DURATION_MS);
assert_eq!(extended_level.elapsed_ms, None);
assert_eq!(extended_level.cleared_at_ms, None);
assert_eq!(extended_level.pause_started_at_ms, None);
assert_eq!(extended_level.freeze_until_ms, None);
}
#[test]
fn pause_and_freeze_are_excluded_from_effective_timer() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let mut run =
start_run_with_shuffle_seed("run-freeze".to_string(), &profile, 0, 12).expect("run");
let now_ms = current_unix_ms();
let level = run.current_level.as_mut().expect("level");
level.started_at_ms = now_ms.saturating_sub(30_000);
level.paused_accumulated_ms = 8_000;
level.pause_started_at_ms = Some(now_ms.saturating_sub(5_000));
level.freeze_accumulated_ms = 4_000;
level.freeze_started_at_ms = Some(now_ms.saturating_sub(3_000));
level.freeze_until_ms = Some(now_ms.saturating_add(7_000));
let remaining_ms = resolve_puzzle_runtime_remaining_ms(level, now_ms);
assert_eq!(remaining_ms, level.time_limit_ms.saturating_sub(10_000));
}
#[test]
fn reference_preview_can_keep_run_paused_until_overlay_closes() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
let run =
start_run_with_shuffle_seed("run-reference".to_string(), &profile, 0, 13).expect("run");
let paused_run = set_puzzle_run_paused(&run, true).expect("pause");
let still_paused_run = set_puzzle_run_paused(&paused_run, true).expect("reference pause");
assert!(
still_paused_run
.current_level
.as_ref()
.and_then(|level| level.pause_started_at_ms)
.is_some()
);
}
#[test]
fn apply_publish_overrides_updates_draft_truth() {
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
let draft = compile_result_draft(&anchor_pack, &[]);
let updated = apply_publish_overrides_to_draft(
&draft,
Some("雨夜猫塔作品".to_string()),
Some("作品描述。".to_string()),
Some("雨夜猫塔".to_string()),
Some("一张更聚焦猫咪塔楼的夜景拼图。".to_string()),
Some(vec![
"雨夜".to_string(),
"猫咪".to_string(),
"遗迹".to_string(),
]),
None,
)
.expect("publish overrides should succeed");
assert_eq!(updated.level_name, "雨夜猫塔");
assert_eq!(updated.work_title, "雨夜猫塔作品");
assert_eq!(updated.summary, "一张更聚焦猫咪塔楼的夜景拼图。");
assert_eq!(
updated.theme_tags,
vec![
"猫咪".to_string(),
"神庙遗迹".to_string(),
"雨夜".to_string()
]
);
}
#[test]
fn apply_publish_overrides_rejects_invalid_tag_count() {
let anchor_pack = infer_anchor_pack("蒸汽城市", Some("蒸汽城市"));
let draft = compile_result_draft(&anchor_pack, &[]);
let error = apply_publish_overrides_to_draft(
&draft,
None,
None,
None,
None,
Some(vec!["蒸汽".to_string()]),
None,
)
.expect_err("invalid tag count should fail");
assert_eq!(error, PuzzleFieldError::InvalidTagCount);
}
#[test]
fn apply_publish_overrides_preserves_empty_level_name_for_publish_gate() {
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
let draft = compile_result_draft(&anchor_pack, &[]);
let mut levels = draft.levels.clone();
levels[0].level_name = " ".to_string();
let updated = apply_publish_overrides_to_draft(
&draft,
Some("雨夜猫塔作品".to_string()),
Some("作品描述。".to_string()),
Some("".to_string()),
Some("作品描述。".to_string()),
Some(vec![
"雨夜".to_string(),
"猫咪".to_string(),
"遗迹".to_string(),
]),
Some(levels),
)
.expect("empty level name should remain editable before publish gate");
assert_eq!(updated.levels[0].level_name, "");
assert!(
validate_publish_requirements(&updated, Some("玩家"))
.iter()
.any(|blocker| blocker.code == "MISSING_LEVEL_NAME")
);
}
}