1
This commit is contained in:
@@ -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;
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -680,7 +681,7 @@ pub fn build_generated_candidates(
|
||||
) -> Result<Vec<PuzzleGeneratedImageCandidate>, PuzzleFieldError> {
|
||||
let session_id =
|
||||
normalize_required_string(session_id).ok_or(PuzzleFieldError::MissingSessionId)?;
|
||||
let count = candidate_count.max(1).min(2);
|
||||
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());
|
||||
|
||||
@@ -690,7 +691,7 @@ pub fn build_generated_candidates(
|
||||
let candidate_id = format!("{session_id}-candidate-{}", index + 1);
|
||||
PuzzleGeneratedImageCandidate {
|
||||
candidate_id: candidate_id.clone(),
|
||||
// 拼图候选图的正式持久化由 api-server 上传 OSS;这里仅保留 reducer
|
||||
// 拼图图片的正式持久化由 api-server 上传 OSS;这里仅保留 reducer
|
||||
// 单测/保底路径构造,前缀必须与 OSS 兼容路由一致,不能再指向 public 目录。
|
||||
image_src: format!(
|
||||
"/generated-puzzle-assets/{session_id}/{candidate_seed}/cover.svg"
|
||||
@@ -884,37 +885,18 @@ pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 {
|
||||
}
|
||||
|
||||
pub fn build_initial_board(grid_size: u32) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||||
build_initial_board_with_seed(grid_size, 0)
|
||||
}
|
||||
|
||||
pub fn build_initial_board_with_seed(
|
||||
grid_size: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||||
if !matches!(grid_size, 3 | 4) {
|
||||
return Err(PuzzleFieldError::InvalidGridSize);
|
||||
}
|
||||
|
||||
let total = grid_size * grid_size;
|
||||
let mut positions = (0..total)
|
||||
.map(|index| PuzzleCellPosition {
|
||||
row: index / grid_size,
|
||||
col: index % grid_size,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if total > 1 {
|
||||
positions.rotate_left(1);
|
||||
}
|
||||
|
||||
let pieces = (0..total)
|
||||
.map(|index| {
|
||||
let correct_row = index / grid_size;
|
||||
let correct_col = index % grid_size;
|
||||
let current = &positions[index as usize];
|
||||
PuzzlePieceState {
|
||||
piece_id: format!("piece-{index}"),
|
||||
correct_row,
|
||||
correct_col,
|
||||
current_row: current.row,
|
||||
current_col: current.col,
|
||||
merged_group_id: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let pieces = build_initial_pieces_without_correct_neighbors(grid_size, shuffle_seed);
|
||||
|
||||
Ok(rebuild_board_snapshot(grid_size, pieces, None))
|
||||
}
|
||||
@@ -925,7 +907,23 @@ pub fn start_run(
|
||||
cleared_level_count: u32,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||
let board = build_initial_board(grid_size)?;
|
||||
let shuffle_seed = puzzle_shuffle_seed(
|
||||
&run_id,
|
||||
&entry_profile.profile_id,
|
||||
cleared_level_count + 1,
|
||||
grid_size,
|
||||
);
|
||||
start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed)
|
||||
}
|
||||
|
||||
pub fn start_run_with_shuffle_seed(
|
||||
run_id: String,
|
||||
entry_profile: &PuzzleWorkProfile,
|
||||
cleared_level_count: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run_id.clone(),
|
||||
entry_profile_id: entry_profile.profile_id.clone(),
|
||||
@@ -989,7 +987,23 @@ pub fn swap_pieces(
|
||||
pieces[second_index].current_row = first_row;
|
||||
pieces[second_index].current_col = first_col;
|
||||
|
||||
let next_board = rebuild_board_snapshot(current_level.grid_size, pieces, None);
|
||||
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,
|
||||
¤t_level.board,
|
||||
pieces,
|
||||
affected_cells,
|
||||
None,
|
||||
);
|
||||
Ok(with_next_board(run, next_board))
|
||||
}
|
||||
|
||||
@@ -1019,13 +1033,91 @@ pub fn drag_piece_or_group(
|
||||
.ok_or(PuzzleFieldError::MissingPieceId)?;
|
||||
let source_group_id = pieces[piece_index].merged_group_id.clone();
|
||||
|
||||
match source_group_id {
|
||||
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,
|
||||
¤t_level.board,
|
||||
pieces,
|
||||
operation_cells,
|
||||
None,
|
||||
);
|
||||
Ok(with_next_board(run, next_board))
|
||||
}
|
||||
|
||||
pub fn rebuild_board_snapshot_for_affected_cells(
|
||||
grid_size: u32,
|
||||
previous_board: &PuzzleBoardSnapshot,
|
||||
pieces: Vec<PuzzlePieceState>,
|
||||
affected_cells: impl IntoIterator<Item = PuzzleCellPosition>,
|
||||
selected_piece_id: Option<String>,
|
||||
) -> PuzzleBoardSnapshot {
|
||||
let affected_scope = expand_affected_cells(grid_size, affected_cells);
|
||||
if affected_scope.is_empty() || previous_board.merged_groups.is_empty() {
|
||||
return rebuild_board_snapshot(grid_size, pieces, selected_piece_id);
|
||||
}
|
||||
|
||||
let next_board = rebuild_board_snapshot(grid_size, pieces, None);
|
||||
Ok(with_next_board(run, next_board))
|
||||
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(
|
||||
@@ -1042,7 +1134,13 @@ pub fn advance_next_level(
|
||||
|
||||
let next_cleared_count = run.cleared_level_count;
|
||||
let next_grid_size = resolve_puzzle_grid_size(next_cleared_count);
|
||||
let next_board = build_initial_board(next_grid_size)?;
|
||||
let shuffle_seed = puzzle_shuffle_seed(
|
||||
&run.run_id,
|
||||
&next_profile.profile_id,
|
||||
run.current_level_index + 1,
|
||||
next_grid_size,
|
||||
);
|
||||
let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?;
|
||||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||||
played_profile_ids.push(next_profile.profile_id.clone());
|
||||
|
||||
@@ -1258,12 +1356,146 @@ fn split_phrase_list(value: &str) -> Vec<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 total = grid_size * grid_size;
|
||||
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_correct_neighbor_pair(&pieces) {
|
||||
return pieces;
|
||||
}
|
||||
}
|
||||
|
||||
// 反序布局等价于把完整棋盘旋转 180 度;任意原图相邻块在当前棋盘中的方向都会反向,
|
||||
// 因此可作为“开局没有正确相邻块”的确定性兜底。
|
||||
let fallback_pieces =
|
||||
build_pieces_from_positions(grid_size, &build_reverse_positions(total, grid_size));
|
||||
debug_assert!(!has_any_correct_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_reverse_positions(total: u32, grid_size: u32) -> Vec<PuzzleCellPosition> {
|
||||
(0..total)
|
||||
.rev()
|
||||
.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_correct_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_correct_neighbors(piece, neighbor))
|
||||
})
|
||||
}
|
||||
|
||||
fn rebuild_board_snapshot(
|
||||
grid_size: u32,
|
||||
mut pieces: Vec<PuzzlePieceState>,
|
||||
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| {
|
||||
@@ -1279,9 +1511,13 @@ fn rebuild_board_snapshot(
|
||||
piece.merged_group_id = group_by_piece.get(&piece.piece_id).cloned();
|
||||
}
|
||||
|
||||
let all_tiles_resolved = pieces.iter().all(|piece| {
|
||||
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,
|
||||
@@ -1293,6 +1529,50 @@ fn rebuild_board_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -1385,17 +1665,32 @@ fn drag_single_piece(
|
||||
piece_index: usize,
|
||||
target_row: u32,
|
||||
target_col: u32,
|
||||
) -> Result<(), PuzzleFieldError> {
|
||||
) -> 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;
|
||||
}
|
||||
}
|
||||
@@ -1410,7 +1705,7 @@ fn drag_single_piece(
|
||||
pieces[target_index].current_row = source_row;
|
||||
pieces[target_index].current_col = source_col;
|
||||
}
|
||||
Ok(())
|
||||
Ok(affected_cells)
|
||||
}
|
||||
|
||||
fn drag_group(
|
||||
@@ -1419,7 +1714,7 @@ fn drag_group(
|
||||
target_row: u32,
|
||||
target_col: u32,
|
||||
grid_size: u32,
|
||||
) -> Result<(), PuzzleFieldError> {
|
||||
) -> Result<Vec<PuzzleCellPosition>, PuzzleFieldError> {
|
||||
let group_indices = pieces
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -1456,8 +1751,19 @@ fn drag_group(
|
||||
.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
|
||||
@@ -1473,6 +1779,14 @@ fn drag_group(
|
||||
.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;
|
||||
}
|
||||
@@ -1480,7 +1794,7 @@ fn drag_group(
|
||||
pieces[*index].current_col = *next_col;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(affected_cells)
|
||||
}
|
||||
|
||||
fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot {
|
||||
@@ -1553,13 +1867,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_candidates_use_oss_compatible_prefix() {
|
||||
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(), 2);
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert!(
|
||||
candidates[0]
|
||||
.image_src
|
||||
@@ -1611,6 +1925,281 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_board_shuffle_changes_by_run_id() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let first = start_run("run-random-a".to_string(), &profile, 0).expect("first run");
|
||||
let second = start_run("run-random-b".to_string(), &profile, 0).expect("second run");
|
||||
let first_positions = first
|
||||
.current_level
|
||||
.expect("first level")
|
||||
.board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| (piece.current_row, piece.current_col))
|
||||
.collect::<Vec<_>>();
|
||||
let second_positions = second
|
||||
.current_level
|
||||
.expect("second level")
|
||||
.board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| (piece.current_row, piece.current_col))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_ne!(first_positions, second_positions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_board_has_no_correct_neighbor_pairs() {
|
||||
for grid_size in [3, 4] {
|
||||
for shuffle_seed in 0..128 {
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
|
||||
|
||||
assert!(board.merged_groups.is_empty());
|
||||
assert!(
|
||||
!has_any_correct_neighbor_pair(&board.pieces),
|
||||
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correct_neighbors_auto_merge_after_swap() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let mut run =
|
||||
start_run_with_shuffle_seed("run-merge".to_string(), &profile, 0, 7).expect("run");
|
||||
let current_level = run.current_level.as_mut().expect("level");
|
||||
current_level.board = rebuild_board_snapshot(
|
||||
3,
|
||||
vec![
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-0".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 0,
|
||||
current_row: 1,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-1".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 1,
|
||||
current_row: 0,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-2".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-3".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 0,
|
||||
current_row: 0,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-4".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 1,
|
||||
current_row: 1,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-5".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-6".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 0,
|
||||
current_row: 0,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-7".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 1,
|
||||
current_row: 1,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-8".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
|
||||
let swapped = swap_pieces(&run, "piece-0", "piece-6").expect("swap");
|
||||
let board = &swapped.current_level.as_ref().expect("level").board;
|
||||
let group = board
|
||||
.merged_groups
|
||||
.iter()
|
||||
.find(|group| {
|
||||
group.piece_ids.contains(&"piece-0".to_string())
|
||||
&& group.piece_ids.contains(&"piece-1".to_string())
|
||||
})
|
||||
.expect("piece-0 and piece-1 should merge");
|
||||
|
||||
assert_eq!(group.piece_ids.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_piece_dragging_into_group_splits_target_group() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let mut run =
|
||||
start_run_with_shuffle_seed("run-split".to_string(), &profile, 0, 9).expect("run");
|
||||
let current_level = run.current_level.as_mut().expect("level");
|
||||
current_level.board = rebuild_board_snapshot(
|
||||
3,
|
||||
vec![
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-0".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 0,
|
||||
current_row: 0,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-1".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 1,
|
||||
current_row: 0,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-2".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-3".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 0,
|
||||
current_row: 1,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-4".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 1,
|
||||
current_row: 1,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-5".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 2,
|
||||
current_row: 1,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-6".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 0,
|
||||
current_row: 2,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-7".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 1,
|
||||
current_row: 2,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-8".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 2,
|
||||
current_row: 0,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
|
||||
let dragged = drag_piece_or_group(&run, "piece-8", 0, 1).expect("drag");
|
||||
let board = &dragged.current_level.as_ref().expect("level").board;
|
||||
|
||||
assert_eq!(
|
||||
board
|
||||
.pieces
|
||||
.iter()
|
||||
.find(|piece| piece.piece_id == "piece-8")
|
||||
.map(|piece| (piece.current_row, piece.current_col)),
|
||||
Some((0, 1))
|
||||
);
|
||||
assert!(
|
||||
board
|
||||
.merged_groups
|
||||
.iter()
|
||||
.all(|group| !(group.piece_ids.contains(&"piece-0".to_string())
|
||||
&& group.piece_ids.contains(&"piece-1".to_string())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_full_board_group_marks_level_cleared() {
|
||||
let pieces = (0..9)
|
||||
.map(|index| PuzzlePieceState {
|
||||
piece_id: format!("piece-{index}"),
|
||||
correct_row: index / 3,
|
||||
correct_col: index % 3,
|
||||
current_row: index / 3,
|
||||
current_col: (index + 1) % 3,
|
||||
merged_group_id: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let board = rebuild_board_snapshot_with_groups(
|
||||
3,
|
||||
pieces,
|
||||
vec![PuzzleMergedGroupState {
|
||||
group_id: "group-full".to_string(),
|
||||
piece_ids: (0..9).map(|index| format!("piece-{index}")).collect(),
|
||||
occupied_cells: (0..9)
|
||||
.map(|index| PuzzleCellPosition {
|
||||
row: index / 3,
|
||||
col: (index + 1) % 3,
|
||||
})
|
||||
.collect(),
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(board.all_tiles_resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_publish_overrides_updates_draft_truth() {
|
||||
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
|
||||
|
||||
Reference in New Issue
Block a user