This commit is contained in:
2026-05-01 01:30:02 +08:00
parent aabad6407f
commit 2e9d0f4640
92 changed files with 4548 additions and 248 deletions

View File

@@ -20,8 +20,16 @@ pub const PUZZLE_EXTEND_TIME_DURATION_MS: u64 = 60_000;
pub const PUZZLE_NEXT_LEVEL_MODE_SAME_WORK: &str = "sameWork";
pub const PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS: &str = "similarWorks";
pub const PUZZLE_NEXT_LEVEL_MODE_NONE: &str = "none";
pub const PUZZLE_SUPPORTED_GRID_SIZES: [u32; 5] = [3, 4, 5, 6, 7];
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
// 中文注释:拼图难度只从关卡序号解析,避免切割规格和倒计时在不同入口各写一套。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PuzzleLevelConfig {
pub grid_size: u32,
pub time_limit_ms: u64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleAgentStage {
@@ -257,6 +265,10 @@ pub struct PuzzleWorkProfile {
pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
#[serde(default)]
pub point_incentive_total_half_points: u64,
#[serde(default)]
pub point_incentive_claimed_points: u64,
pub publish_ready: bool,
pub anchor_pack: PuzzleAnchorPack,
}
@@ -540,6 +552,14 @@ pub struct PuzzleWorkLikeRecordInput {
pub liked_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkPointIncentiveClaimInput {
pub profile_id: String,
pub owner_user_id: String,
pub claimed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleRunStartInput {
@@ -602,6 +622,8 @@ pub struct PuzzleRunPropInput {
pub owner_user_id: String,
pub prop_kind: String,
pub used_at_micros: i64,
#[serde(default)]
pub spent_points: u64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -1298,6 +1320,8 @@ pub fn create_work_profile(
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(),
})
@@ -1411,20 +1435,81 @@ pub fn normalize_puzzle_levels(
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 {
if cleared_level_count >= 3 { 4 } else { 3 }
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 {
4 => 300_000,
_ => 180_000,
3 | 4 | 5 => 300_000,
6 => 240_000,
7 => 270_000,
_ => 300_000,
}
}
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
let time_limit_ms = if level.time_limit_ms == 0 {
resolve_puzzle_level_time_limit_ms(level.grid_size)
resolve_puzzle_level_time_limit_ms_by_index(level.level_index)
} else {
level.time_limit_ms
};
@@ -1436,7 +1521,7 @@ fn normalize_timer_fields(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) {
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);
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;
@@ -1612,7 +1697,7 @@ pub fn build_initial_board_with_seed(
grid_size: u32,
shuffle_seed: u64,
) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
if !matches!(grid_size, 3 | 4) {
if !is_supported_puzzle_grid_size(grid_size) {
return Err(PuzzleFieldError::InvalidGridSize);
}
@@ -1678,19 +1763,21 @@ pub fn start_run_with_shuffle_seed_at(
shuffle_seed: u64,
started_at_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
let level_index = cleared_level_count + 1;
let level_config = resolve_puzzle_level_config(level_index);
let grid_size = level_config.grid_size;
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
cleared_level_count,
current_level_index: cleared_level_count + 1,
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: cleared_level_count + 1,
level_index,
level_id: entry_profile
.levels
.first()
@@ -1706,8 +1793,8 @@ pub fn start_run_with_shuffle_seed_at(
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),
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,
@@ -1938,11 +2025,13 @@ pub fn advance_next_level_at(
}
let next_cleared_count = run.cleared_level_count;
let next_grid_size = resolve_puzzle_grid_size(next_cleared_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,
run.current_level_index + 1,
next_level_index,
next_grid_size,
);
let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?;
@@ -1953,13 +2042,13 @@ pub fn advance_next_level_at(
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id.clone(),
cleared_level_count: next_cleared_count,
current_level_index: run.current_level_index + 1,
current_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: run.current_level_index + 1,
level_index: next_level_index,
level_id: next_profile
.levels
.first()
@@ -1975,8 +2064,81 @@ pub fn advance_next_level_at(
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),
time_limit_ms: next_level_config.time_limit_ms,
remaining_ms: next_level_config.time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: default_puzzle_next_level_mode(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
})
}
pub fn advance_to_new_work_first_level_at(
run: &PuzzleRunSnapshot,
next_profile: &PuzzleWorkProfile,
started_at_ms: u64,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let current_level = run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err(PuzzleFieldError::InvalidOperation);
}
// 中文注释:跨作品代表进入一个新作品,关卡序号、切割规格和倒计时都从第 1 关重新开始。
let next_level_index = 1;
let level_config = resolve_puzzle_level_config(next_level_index);
let grid_size = level_config.grid_size;
let shuffle_seed = puzzle_shuffle_seed(
&run.run_id,
&next_profile.profile_id,
next_level_index,
grid_size,
);
let next_board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let mut played_profile_ids = run.played_profile_ids.clone();
if !played_profile_ids.contains(&next_profile.profile_id) {
played_profile_ids.push(next_profile.profile_id.clone());
}
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
entry_profile_id: next_profile.profile_id.clone(),
cleared_level_count: 0,
current_level_index: next_level_index,
current_grid_size: grid_size,
played_profile_ids,
previous_level_tags: next_profile.theme_tags.clone(),
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id: run.run_id.clone(),
level_index: next_level_index,
level_id: next_profile
.levels
.first()
.map(|level| level.level_id.clone()),
grid_size,
profile_id: next_profile.profile_id.clone(),
level_name: next_profile.level_name.clone(),
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms: level_config.time_limit_ms,
remaining_ms: level_config.time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
@@ -2058,6 +2220,11 @@ pub fn selected_profile_level_index(profile: &PuzzleWorkProfile, level_id: &str)
.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],
@@ -2618,7 +2785,8 @@ fn build_initial_pieces_without_correct_neighbors(
}
// 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。
let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed)
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
@@ -2686,6 +2854,124 @@ fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) ->
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,
@@ -3212,6 +3498,8 @@ mod tests {
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(),
}
@@ -3220,8 +3508,33 @@ mod tests {
#[test]
fn resolve_grid_size_matches_prd() {
assert_eq!(resolve_puzzle_grid_size(0), 3);
assert_eq!(resolve_puzzle_grid_size(2), 3);
assert_eq!(resolve_puzzle_grid_size(3), 4);
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]
@@ -3360,6 +3673,66 @@ mod tests {
assert_eq!(selected.profile_id, "b");
}
#[test]
fn restart_cleared_count_uses_selected_level_index() {
let mut profile = build_published_profile("entry", "owner-a", vec!["机关"]);
profile.levels = vec![
PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: "第一关".to_string(),
picture_description: "第一关画面".to_string(),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-1.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
},
PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
},
];
assert_eq!(
resolve_restart_cleared_level_count(&profile, "puzzle-level-2"),
1
);
assert_eq!(
resolve_restart_cleared_level_count(&profile, "missing-level"),
0
);
}
#[test]
fn advance_to_new_work_first_level_restarts_level_progress() {
let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻", "遗迹"]);
let next_profile = build_published_profile("next", "owner-b", vec!["奇幻", "魔法"]);
let mut run = start_run("run-cross-work".to_string(), &first_profile, 2).expect("run");
run.cleared_level_count = run.current_level_index;
let current_level = run.current_level.as_mut().expect("level");
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_run =
advance_to_new_work_first_level_at(&run, &next_profile, 3_000).expect("next run");
assert_eq!(next_run.entry_profile_id, "next");
assert_eq!(next_run.cleared_level_count, 0);
assert_eq!(next_run.current_level_index, 1);
let next_level = next_run.current_level.expect("next level");
assert_eq!(next_level.profile_id, "next");
assert_eq!(next_level.level_index, 1);
assert_eq!(next_level.grid_size, 3);
assert_eq!(next_level.time_limit_ms, 300_000);
}
#[test]
fn swap_pieces_marks_cleared_when_back_to_origin() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
@@ -3408,7 +3781,7 @@ mod tests {
#[test]
fn initial_board_has_no_original_neighbor_pairs() {
for grid_size in [3, 4] {
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");
@@ -3672,6 +4045,28 @@ mod tests {
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!["蒸汽城市", "雨夜", "猫咪"]);