This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 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;
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,
&current_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,
&current_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("雨夜猫咪神庙"));