1
This commit is contained in:
@@ -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 {
|
||||
// 中文注释:失败重开指定的是当前关 levelId;start_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!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
|
||||
Reference in New Issue
Block a user