This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -15,6 +15,7 @@ pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-";
pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-";
pub const PUZZLE_MIN_TAG_COUNT: usize = 3;
pub const PUZZLE_MAX_TAG_COUNT: usize = 6;
pub const PUZZLE_FREEZE_TIME_DURATION_MS: u64 = 10_000;
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -65,6 +66,7 @@ pub enum PuzzlePublicationStatus {
pub enum PuzzleRuntimeLevelStatus {
Playing,
Cleared,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -214,6 +216,8 @@ pub struct PuzzleWorkProfile {
pub remix_count: u32,
#[serde(default)]
pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
pub publish_ready: bool,
pub anchor_pack: PuzzleAnchorPack,
}
@@ -260,7 +264,9 @@ pub struct PuzzleBoardSnapshot {
pub cols: u32,
pub pieces: Vec<PuzzlePieceState>,
pub merged_groups: Vec<PuzzleMergedGroupState>,
#[serde(default)]
pub selected_piece_id: Option<String>,
#[serde(default)]
pub all_tiles_resolved: bool,
}
@@ -277,9 +283,27 @@ pub struct PuzzleRuntimeLevelSnapshot {
pub cover_image_src: Option<String>,
pub board: PuzzleBoardSnapshot,
pub status: PuzzleRuntimeLevelStatus,
#[serde(default)]
pub started_at_ms: u64,
#[serde(default)]
pub cleared_at_ms: Option<u64>,
#[serde(default)]
pub elapsed_ms: Option<u64>,
#[serde(default)]
pub time_limit_ms: u64,
#[serde(default)]
pub remaining_ms: u64,
#[serde(default)]
pub paused_accumulated_ms: u64,
#[serde(default)]
pub pause_started_at_ms: Option<u64>,
#[serde(default)]
pub freeze_accumulated_ms: u64,
#[serde(default)]
pub freeze_started_at_ms: Option<u64>,
#[serde(default)]
pub freeze_until_ms: Option<u64>,
#[serde(default)]
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
@@ -295,6 +319,7 @@ pub struct PuzzleRunSnapshot {
pub previous_level_tags: Vec<String>,
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
pub recommended_next_profile_id: Option<String>,
#[serde(default)]
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
@@ -470,6 +495,24 @@ pub struct PuzzleRunNextLevelInput {
pub advanced_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunPauseInput {
pub run_id: String,
pub owner_user_id: String,
pub paused: bool,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunPropInput {
pub run_id: String,
pub owner_user_id: String,
pub prop_kind: String,
pub used_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardSubmitInput {
@@ -605,6 +648,7 @@ impl PuzzleRuntimeLevelStatus {
match self {
Self::Playing => "playing",
Self::Cleared => "cleared",
Self::Failed => "failed",
}
}
}
@@ -648,6 +692,10 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
.or_else(|| normalize_required_string(seed_text))
.unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string());
if let Some((title, picture_description)) = parse_form_seed_text(&source) {
return build_form_anchor_pack(title.as_str(), picture_description.as_str());
}
let mut pack = empty_anchor_pack();
pack.theme_promise.value = infer_theme_promise(&source);
pack.theme_promise.status = PuzzleAnchorStatus::Inferred;
@@ -662,12 +710,38 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl
pack
}
pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack {
let normalized_title =
normalize_required_string(title).unwrap_or_else(|| "奇景拼图".to_string());
let normalized_description =
normalize_required_string(picture_description).unwrap_or_else(|| normalized_title.clone());
let mut pack = empty_anchor_pack();
pack.theme_promise.value = normalized_title.clone();
pack.theme_promise.status = PuzzleAnchorStatus::Locked;
pack.visual_subject.value = normalized_description.clone();
pack.visual_subject.status = PuzzleAnchorStatus::Locked;
pack.visual_mood.value = "清晰、适合拼图切块".to_string();
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
pack.tags_and_forbidden.value =
build_form_tags_and_forbidden(normalized_title.as_str(), normalized_description.as_str());
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
pack
}
pub fn build_creator_intent(
anchor_pack: &PuzzleAnchorPack,
messages: &[PuzzleAgentMessageSnapshot],
) -> PuzzleCreatorIntent {
PuzzleCreatorIntent {
source_mode: "agent_chat".to_string(),
source_mode: if is_form_anchor_pack(anchor_pack) {
"form".to_string()
} else {
"agent_chat".to_string()
},
raw_messages_summary: messages
.iter()
.rev()
@@ -698,12 +772,7 @@ pub fn compile_result_draft(
let level_name = build_level_name(anchor_pack, &normalized_tags);
PuzzleResultDraft {
level_name,
summary: format!(
"{},主体是{},氛围偏{}。",
fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"),
fallback_text(&anchor_pack.visual_subject.value, "画面主体"),
fallback_text(&anchor_pack.visual_mood.value, "温暖")
),
summary: build_result_summary(anchor_pack),
theme_tags: normalized_tags,
forbidden_directives: creator_intent.forbidden_directives.clone(),
creator_intent: Some(creator_intent),
@@ -866,6 +935,7 @@ pub fn create_work_profile(
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
publish_ready: preview.publish_ready,
anchor_pack: draft.anchor_pack.clone(),
})
@@ -930,6 +1000,159 @@ pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 {
if cleared_level_count >= 3 { 4 } else { 3 }
}
pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 {
match grid_size {
4 => 300_000,
_ => 180_000,
}
}
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
let time_limit_ms = if level.time_limit_ms == 0 {
resolve_puzzle_level_time_limit_ms(level.grid_size)
} 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(level.grid_size);
}
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 build_initial_board(grid_size: u32) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
build_initial_board_with_seed(grid_size, 0)
}
@@ -951,6 +1174,20 @@ 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(
@@ -959,7 +1196,13 @@ pub fn start_run(
cleared_level_count + 1,
grid_size,
);
start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed)
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(
@@ -967,10 +1210,25 @@ pub fn start_run_with_shuffle_seed(
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 grid_size = resolve_puzzle_grid_size(cleared_level_count);
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let started_at_ms = current_unix_ms();
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
@@ -993,6 +1251,13 @@ pub fn start_run_with_shuffle_seed(
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms: resolve_puzzle_level_time_limit_ms(grid_size),
remaining_ms: resolve_puzzle_level_time_limit_ms(grid_size),
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,
@@ -1004,16 +1269,26 @@ 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 current_level = run
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::Cleared {
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
return Err(PuzzleFieldError::InvalidOperation);
}
let mut pieces = current_level.board.pieces.clone();
@@ -1056,7 +1331,7 @@ pub fn swap_pieces(
affected_cells,
None,
);
Ok(with_next_board(run, next_board))
Ok(with_next_board_at(&timed_run, next_board, now_ms))
}
pub fn drag_piece_or_group(
@@ -1064,13 +1339,24 @@ pub fn drag_piece_or_group(
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 current_level = run
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::Cleared {
if current_level.status != PuzzleRuntimeLevelStatus::Playing {
return Err(PuzzleFieldError::InvalidOperation);
}
let grid_size = current_level.grid_size;
@@ -1097,7 +1383,7 @@ pub fn drag_piece_or_group(
operation_cells,
None,
);
Ok(with_next_board(run, next_board))
Ok(with_next_board_at(&timed_run, next_board, now_ms))
}
pub fn rebuild_board_snapshot_for_affected_cells(
@@ -1175,6 +1461,14 @@ pub fn rebuild_board_snapshot_for_affected_cells(
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
@@ -1215,9 +1509,16 @@ pub fn advance_next_level(
cover_image_src: next_profile.cover_image_src.clone(),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms: current_unix_ms(),
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms: resolve_puzzle_level_time_limit_ms(next_grid_size),
remaining_ms: resolve_puzzle_level_time_limit_ms(next_grid_size),
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,
@@ -1382,6 +1683,100 @@ fn infer_tags_and_forbidden(source: &str) -> String {
}
}
fn parse_form_seed_text(source: &str) -> Option<(String, String)> {
let normalized_source = source.trim();
let title_marker = "拼图标题:";
let description_marker = "画面描述:";
let title_start = normalized_source.find(title_marker)? + title_marker.len();
let description_start = normalized_source.find(description_marker)?;
if description_start <= title_start {
return None;
}
let title = normalize_required_string(&normalized_source[title_start..description_start]);
let picture_description = normalize_required_string(
&normalized_source[description_start + description_marker.len()..],
);
match (title, picture_description) {
(Some(title), Some(picture_description)) => Some((title, picture_description)),
_ => None,
}
}
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 extract_forbidden_directive(source: &str) -> String {
if let Some((_, tail)) = source.split_once('') {
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
@@ -1390,6 +1785,12 @@ fn extract_forbidden_directive(source: &str) -> String {
}
fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String {
if is_form_anchor_pack(anchor_pack)
&& let Some(title) = normalize_required_string(&anchor_pack.theme_promise.value)
{
return title;
}
if let Some(tag) = normalized_tags.first() {
return format!("{tag}拼图");
}
@@ -1970,7 +2371,11 @@ fn drag_group(
Ok(affected_cells)
}
fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot {
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 {
@@ -1982,13 +2387,10 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
if let Some(current_level) = next_run.current_level.as_mut() {
current_level.board = next_board;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
let cleared_at_ms = current_unix_ms();
current_level.cleared_at_ms = Some(cleared_at_ms);
current_level.elapsed_ms = Some(
cleared_at_ms
.saturating_sub(current_level.started_at_ms)
.max(1_000),
);
current_level.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;
}
@@ -2011,6 +2413,10 @@ fn current_unix_ms() -> u64 {
.unwrap_or(0)
}
pub fn current_puzzle_unix_micros() -> i64 {
(current_unix_ms() as i64).saturating_mul(1_000)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2079,6 +2485,53 @@ mod tests {
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画面描述:一只猫在雨夜灯牌下回头。",
None,
);
let draft = compile_result_draft(&anchor_pack, &[]);
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.level_name, "暖灯猫街");
assert_eq!(draft.summary, "一只猫在雨夜灯牌下回头。");
assert_eq!(
draft
.creator_intent
.as_ref()
.map(|intent| intent.source_mode.as_str()),
Some("form")
);
assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT);
}
#[test]
fn form_seed_keeps_multiline_picture_description() {
let anchor_pack = infer_anchor_pack(
"拼图标题:雨夜猫街\n画面描述:一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。",
None,
);
let draft = compile_result_draft(&anchor_pack, &[]);
assert_eq!(
anchor_pack.visual_subject.value,
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
);
assert_eq!(
draft.summary,
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
);
assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪"));
assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜"));
}
#[test]
fn tag_similarity_score_uses_jaccard() {
let score = tag_similarity_score(
@@ -2396,6 +2849,59 @@ mod tests {
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 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("雨夜猫咪神庙"));